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
index 3753210b8..a42959190 100644
--- a/.github/actions/gradle-setup/action.yml
+++ b/.github/actions/gradle-setup/action.yml
@@ -27,19 +27,14 @@ runs:
distribution: ${{ inputs.jdk_distribution }}
token: ${{ github.token }}
- # Robolectric downloads instrumented SDK jars from Maven Central at test time.
- # Cache them to avoid flaky SocketException failures on CI runners.
- # Update the key when bumping robolectric version in libs.versions.toml or sdk in robolectric.properties.
- - name: Cache Robolectric SDK jars
- uses: actions/cache@v5
- with:
- path: ~/.m2/repository/org/robolectric
- key: robolectric-4.16.1-sdk34
-
- 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
\ No newline at end of file
+ add-job-summary: always
+ gradle-home-cache-includes: |
+ caches
+ notifications
+ ~/.m2/repository/org/robolectric
\ No newline at end of file
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 2e60f3dff..e856cbe8f 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,6 +1,6 @@
-# Meshtastic Android - Agent Guide
+# Meshtastic Android - GitHub Copilot Guide
-**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically.
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
-See [AGENTS.md](../AGENTS.md) for architecture, conventions, execution protocol, and coding standards.
-See [docs/agent-playbooks/README.md](../docs/agent-playbooks/README.md) for version baselines and task recipes.
+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/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 e67a217c7..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,108 +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-24.04' }}
- 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: '21'
- token: ${{ github.token }}
-
- # 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/docs.yml b/.github/workflows/docs.yml
index 568da41f4..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,11 +39,11 @@ 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:
@@ -56,7 +66,7 @@ jobs:
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
- uses: actions/upload-pages-artifact@v4
+ uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml
index 4c29847a3..eaf3f54d3 100644
--- a/.github/workflows/main-check.yml
+++ b/.github/workflows/main-check.yml
@@ -20,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/merge-queue.yml b/.github/workflows/merge-queue.yml
index 2818ca939..44d31183d 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -18,8 +18,6 @@ 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
diff --git a/.github/workflows/models_pr_triage.yml b/.github/workflows/models_pr_triage.yml
index 2cfe6b15e..c2a1aaf25 100644
--- a/.github/workflows/models_pr_triage.yml
+++ b/.github/workflows/models_pr_triage.yml
@@ -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.
@@ -94,6 +97,9 @@ jobs:
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: |
@@ -105,8 +111,8 @@ jobs:
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
diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml
index 2338a6aeb..df16866f3 100644
--- a/.github/workflows/promote.yml
+++ b/.github/workflows/promote.yml
@@ -139,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/pull-request.yml b/.github/workflows/pull-request.yml
index 6649dbc84..d450711ce 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -3,10 +3,6 @@ name: Pull Request CI
on:
pull_request:
branches: [ main ]
- paths-ignore:
- - '**/*.md'
- - 'docs/**'
- - '.gitignore'
permissions:
contents: read
@@ -39,7 +35,6 @@ jobs:
- 'desktop/**'
- 'core/**'
- 'feature/**'
- - 'mesh_service_example/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
@@ -99,7 +94,9 @@ jobs:
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
- # We disable instrumented tests and coverage 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
if: needs.check-changes.outputs.android == 'true'
@@ -107,9 +104,8 @@ jobs:
with:
run_lint: true
run_unit_tests: true
- run_instrumented_tests: false
run_coverage: false
- api_levels: '[35]'
+ run_desktop_builds: false
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 77687a105..40d8e40f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -328,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 }}
@@ -341,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 75557fe00..632bf1ea4 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -9,15 +9,12 @@ on:
run_unit_tests:
type: boolean
default: true
- run_instrumented_tests:
- type: boolean
- default: true
run_coverage:
type: boolean
default: true
- api_levels:
- type: string
- default: '[35]'
+ run_desktop_builds:
+ type: boolean
+ default: true
upload_artifacts:
type: boolean
default: true
@@ -97,7 +94,7 @@ jobs:
- 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 mesh_service_example:lintDebug kmpSmokeCompile -Pci=true --continue --scan
+ 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
@@ -176,14 +173,12 @@ jobs:
:desktop:test
:core:barcode:testFdroidDebugUnitTest
:core:barcode:testGoogleDebugUnitTest
- :mesh_service_example:test
kover: >-
:app:koverXmlReportFdroidDebug
:app:koverXmlReportGoogleDebug
:core:barcode:koverXmlReportFdroidDebug
:core:barcode:koverXmlReportGoogleDebug
:desktop:koverXmlReport
- :mesh_service_example:koverXmlReportDebug
steps:
- name: Checkout code
@@ -218,7 +213,7 @@ jobs:
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
- if: ${{ !cancelled() }}
+ if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -237,7 +232,7 @@ jobs:
**/build/test-results
retention-days: 7
- # ── Android Build & Instrumented Tests ──────────────────────────────
+ # ── Android Build ────────────────────────────────────────────────────
android-check:
runs-on: ubuntu-24.04
permissions:
@@ -246,10 +241,6 @@ jobs:
needs: lint-check
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
- strategy:
- fail-fast: true
- matrix:
- api_level: ${{ fromJson(inputs.api_levels) }}
steps:
- name: Checkout code
@@ -264,108 +255,38 @@ jobs:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- - 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"
- )
- 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@v6
- 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 "| 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 }}
- uses: actions/upload-artifact@v7
- with:
- name: reports-android-api-${{ matrix.api_level }}
- path: |
- **/build/outputs/androidTest-results
- retention-days: 7
- if-no-files-found: ignore
-
# ── Desktop Build ───────────────────────────────────────────────────
build-desktop:
- name: Build Desktop Debug
- runs-on: ubuntu-24.04
+ 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 }}
@@ -383,12 +304,12 @@ jobs:
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Desktop
- run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan
+ run: ./gradlew :desktop:createDistributable -Pci=true --scan
- name: Upload Desktop artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: desktop-app
- path: desktop/build/compose/binaries/main/app/Meshtastic/bin/*
+ name: desktop-app-${{ runner.os }}-${{ runner.arch }}
+ path: desktop/build/compose/binaries/main/app/
retention-days: 7
diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml
index d516537e0..2399d1f88 100644
--- a/.github/workflows/scheduled-updates.yml
+++ b/.github/workflows/scheduled-updates.yml
@@ -2,8 +2,8 @@ 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:
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 ed603d08a..c1bafdd96 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,208 +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 21 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 (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production").
-- **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 (Scene-based architecture) with shared backstack state. Deep linking uses RESTful paths (e.g. `/nodes/1234`) parsed by `DeepLinkRouter` in `core:navigation`.
- - **Lifecycle:** JetBrains multiplatform `lifecycle-viewmodel-compose` and `lifecycle-runtime-compose`.
- - **Adaptive UI:** Material 3 Adaptive (v1.3+) with support for Large (1200dp) and Extra-large (1600dp) breakpoints.
- - **Database:** Room KMP.
+
+- **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.kmp.jvm.android`, `meshtastic.koin`). |
-| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
-| `docs/` | Architecture docs and agent playbooks. See `docs/agent-playbooks/README.md` for version baseline and task recipes. |
-| `core/model` | Domain models and common data structures. |
-| `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 per feature domain (e.g., `SettingsRoute`, `NodesRoute`). `DeepLinkRouter` for typed backstack synthesis, and `MeshtasticNavSavedStateConfig` with `subclassesOfSealed()` for automatic polymorphic backstack persistence — new routes are registered at compile time. |
-| `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 with `jvm()` and `ios()` targets except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
-| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Scans for provisioning devices, lists available networks, applies credentials. Uses `core:ble` Kable abstractions. |
-| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi via Kable/Ktor), native Nordic Secure DFU protocol (pure KMP, no Nordic library), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
-| `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/` | **DEPRECATED — scheduled for removal.** Legacy sample app showing `core:api` service integration. Do not add code here. See `core/api/README.md` for the current integration guide. |
+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.
-- **String formatting:** CMP's `stringResource(res, args)` / `getString(res, args)` only support `%N$s` (string) and `%N$d` (integer) positional specifiers. Float formats like `%N$.1f` are NOT supported — they pass through unsubstituted. For float values, pre-format in Kotlin using `NumberFormatter.format(value, decimalPlaces)` from `core:common` and pass the result as a `%N$s` string arg. Use bare `%` (not `%%`) for literal percent signs in CMP-consumed strings, since CMP does not convert `%%` to `%`. For JVM-only code using `formatString()` (which wraps `String.format()`), full printf specifiers including `%N$.Nf` and `%%` are supported.
-- **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`). For global responses like traceroute and firmware validation, use the specialized common handlers: `TracerouteAlertHandler(uiViewModel)` and `FirmwareVersionCheck(uiViewModel)`. Do NOT duplicate inline alert-rendering logic or trigger alerts directly during composition. For shared QR/contact dialogs, use the `SharedDialogs(uiViewModel)` composable.
-- **Placeholders:** For desktop/JVM features not yet implemented, use `PlaceholderScreen(name)` from `core:ui/commonMain`. Do NOT define inline placeholder composables in feature modules.
-- **Theme Picker:** Use `ThemePickerDialog` and `ThemeOption` from `feature:settings/commonMain`. Do NOT duplicate the theme dialog or enum in platform-specific source sets.
-- **Adaptive Layouts:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support the 2026 Desktop Experience breakpoints. Prioritize **higher information density** and mouse-precision interactions for Desktop and External Display (Android 16 QPR3) targets. **Investigate 3-pane "Power User" scenes** (e.g., Node List + Detail + Map/Charts) using Navigation 3 Scenes, `extraPane()`, and draggable dividers (`VerticalDragHandle` + `paneExpansionState`) for widths ≥ 1200dp.
-- **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`). Note: JetBrains now recommends `kotlinx-io` as the official Kotlin I/O library (built on Okio). This project standardizes on Okio directly; do not migrate without explicit decision.
- - `kotlinx.coroutines.Dispatchers.IO` → `org.meshtastic.core.common.util.ioDispatcher` (expect/actual). Note: `Dispatchers.IO` is available in `commonMain` since kotlinx.coroutines 1.8.0, but this project uses the `ioDispatcher` wrapper for consistency.
-- **Shared helpers over duplicated lambdas:** When `androidMain` and `jvmMain` contain identical pure-Kotlin logic (formatting, action dispatch, validation), extract it to a function in `commonMain`. Examples: `formatLogsTo()` in `feature:settings`, `handleNodeAction()` in `feature:node`, `findNodeByNameSuffix()` in `feature:connections`, `MeshtasticAppShell` in `core:ui/commonMain`, and `BaseRadioTransportFactory` in `core:network/commonMain`.
-- **KMP file naming:** In KMP modules, `commonMain` and platform source sets (`androidMain`, `jvmMain`) share the same package namespace. If both contain a file with the same name (e.g., `LogExporter.kt`), the Kotlin/JVM compiler will produce a duplicate class error. Use distinct filenames: keep the `expect` declaration in `LogExporter.kt` and put shared helpers in a separate file like `LogFormatter.kt`.
-- **`jvmAndroidMain` source set:** Modules that share JVM-specific code between Android and Desktop apply the `meshtastic.kmp.jvm.android` convention plugin. This creates a `jvmAndroidMain` source set via Kotlin's hierarchy template API. Used in `core:common`, `core:model`, `core:data`, `core:network`, and `core:ui`.
-- **Hierarchy template first:** Prefer Kotlin's default hierarchy template and convention plugins over manual `dependsOn(...)` graphs. Manual source-set wiring should be reserved for cases the template cannot model.
-- **`expect`/`actual` restraint:** Prefer interfaces + DI for platform capabilities; use `expect`/`actual` for small unavoidable platform primitives. Avoid broad expect/actual class hierarchies when an interface-based boundary is sufficient.
-- **Feature navigation graphs:** Feature modules export Navigation 3 graph functions as extension functions on `EntryProviderScope` in `commonMain` (e.g., `fun EntryProviderScope.settingsGraph(backStack: NavBackStack)`). Host shells (`app`, `desktop`) assemble these into a single `entryProvider` block. Do NOT define navigation graphs in platform-specific source sets.
-- **Concurrency:** Use Kotlin Coroutines and Flow.
-- **Dependency Injection:** Use **Koin Annotations** with the K2 compiler plugin (`koin-plugin` in version catalog). The `koin-annotations` library version is unified with `koin-core` (both use `version.ref = "koin"`). The `KoinConventionPlugin` uses the typed `KoinGradleExtension` to configure the K2 plugin (e.g., `compileSafety.set(false)`). Keep root graph assembly in `app`.
-- **ViewModels:** Follow the MVI/UDF pattern. Use the multiplatform `androidx.lifecycle.ViewModel` in `commonMain`. Both `app` and `desktop` use `MeshtasticNavDisplay` from `core:ui/commonMain`, which configures `ViewModelStoreNavEntryDecorator` + `SaveableStateHolderNavEntryDecorator`. ViewModels obtained via `koinViewModel()` inside `entry` blocks are scoped to the entry's backstack lifetime and cleared on pop.
-- **Navigation host:** Use `MeshtasticNavDisplay` from `core:ui/commonMain` instead of calling `NavDisplay` directly. It provides entry-scoped ViewModel decoration, `DialogSceneStrategy` for dialog entries, and a shared 350 ms crossfade transition. Host modules (`app`, `desktop`) should NOT configure `entryDecorators`, `sceneStrategies`, or `transitionSpec` themselves.
-- **BLE:** All Bluetooth communication must route through `core:ble` using Kable.
-- **Networking:** Pure **Ktor** — no OkHttp anywhere. Engines: `ktor-client-android` for Android, `ktor-client-java` for desktop/JVM. Use Ktor `Logging` plugin for HTTP debug logging (not OkHttp interceptors). `HttpClient` is provided via Koin in `app/di/NetworkModule` and `core:network/di/CoreNetworkAndroidModule`.
-- **Image Loading (Coil):** Use `coil-network-ktor3` with `KtorNetworkFetcherFactory` on **all** platforms. `ImageLoader` is configured in host modules only (`app` via Koin `@Single`, `desktop` via `setSingletonImageLoaderFactory`). Feature modules depend only on `libs.coil` (coil-compose) for `AsyncImage` — never add `coil-network-*` or `coil-svg` to feature modules.
-- **Dependencies:** Check `gradle/libs.versions.toml` before assuming a library is available.
-- **AboutLibraries:** Runs in `offlineMode` by default (no GitHub/SPDX API calls). Release builds pass `-PaboutLibraries.release=true` via Fastlane properties (Android) or Gradle CLI (desktop) to enable remote license/funding fetching. Do NOT re-gate on `CI` or `GITHUB_TOKEN` alone — that burns API calls on every PR check.
-- **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 21 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 allTests
-```
-
-**Testing:**
-```bash
-# Full host-side unit test run (required — see note below):
-./gradlew test allTests
-
-# Pure-Android / pure-JVM modules only (app, desktop, core:api, core:barcode, feature:widget, mesh_service_example):
-./gradlew test
-
-# KMP modules only (all core:* KMP + all feature:* KMP modules — jvmTest + testAndroidHostTest + iosSimulatorArm64Test):
-./gradlew allTests
-
-# CI-aligned flavor-explicit Android unit tests:
-./gradlew testFdroidDebugUnitTest testGoogleDebugUnitTest
-
-./gradlew connectedAndroidTest # Run instrumented tests
-./gradlew testFdroidDebug testGoogleDebug # Flavor-specific unit tests
-./gradlew lintFdroidDebug lintGoogleDebug # Flavor-specific lint checks
-```
-
-> **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 all 25 KMP modules.
-> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP Gradle plugin for each
-> KMP module. It runs `jvmTest`, `testAndroidHostTest` (where declared with `withHostTest {}`), and
-> `iosSimulatorArm64Test` (disabled at execution — iOS targets are compile-only). Conversely,
-> `allTests` does **not** cover the pure-Android modules (`:app`, `:core:api`, `:core:barcode`,
-> `:feature:widget`, `:mesh_service_example`, `:desktop`), which is why both are needed.
-
-*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin your tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
-
-**CI workflow conventions (GitHub Actions):**
-- Reusable CI in `.github/workflows/reusable-check.yml` is structured as four parallel job groups:
- 1. **`lint-check`** — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation. 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`, `mesh_service_example`).
- Each shard generates its own Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags.
- Downstream jobs (test-shards, android-check, build-desktop) use `fetch-depth: 1` and receive `VERSION_CODE` from lint-check via env var, enabling shallow clones.
- 3. **`android-check`** — Builds APKs and runs instrumented tests (depends on `lint-check`).
- 4. **`build-desktop`** — Desktop packaging (depends on `lint-check`).
-- Test sharding uses `fail-fast: false` so a failure in one shard does not cancel the others.
-- JUnit Platform parallel execution is enabled project-wide with classes running sequentially (`junit.jupiter.execution.parallel.mode.classes.default=same_thread`) to avoid `Dispatchers.setMain()` races (JVM-global singleton used by 19+ ViewModel test classes). Cross-module parallelism comes from Gradle forks (`maxParallelForks`).
-- `test-retry` plugin (maxRetries=2, maxFailures=10) is applied to all module types: `AndroidApplicationConventionPlugin`, `AndroidLibraryConventionPlugin`, and `KmpLibraryConventionPlugin`.
-- 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.
-- **Runner strategy (three tiers):**
- - **`ubuntu-24.04-arm`** — Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). These run only shell scripts or GitHub API calls and benefit from ARM runners' shorter queue times.
- - **`ubuntu-24.04`** — Main Gradle-heavy jobs (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin where possible for reproducibility.
- - **Desktop runners:** Reusable CI uses `ubuntu-24.04` for the `build-desktop` job in `.github/workflows/reusable-check.yml`; release packaging matrix remains `[macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]`.
-- **CI 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` before any Gradle invocation. 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`). This follows the nowinandroid `ci-gradle.properties` pattern.
-- **CI optimization strategies (2026):** Applied comprehensive CI optimizations (P0-P3):
- - **P0 (merged Gradle invocations):** `lint-check` merges spotlessCheck, detekt, android lint, and kmpSmokeCompile into a single Gradle invocation to avoid 3x cold-start overhead. Uses `filter: 'blob:none'` for blobless git clone. Switches submodules from `'recursive'` to boolean (saves overhead on nested submodule discovery).
- - **P1 (reduced PR overhead):** Added `run_coverage` workflow input (default: true); PRs skip Kover reports via conditional tasks in test-shards matrix. Increased `maxParallelForks` in CI to use all available processors (4 on standard runners) when `ci=true` property is set, vs. half locally for system responsiveness.
- - **P2 (build feature optimization):** Detekt disables non-essential report formats in CI (html, txt, md); retains only xml + sarif for GitHub annotations. Disables unused Android build features (resvalues, shaders) in `ci-gradle.properties`.
- - **P3 (structural improvement):** Removed `verify-check-changes-filter` from `validate-and-build` dependencies; it now runs in parallel as a standalone required check instead of gating the main build.
-- **`maxParallelForks` CI logic:** ProjectExtensions.kt line ~79 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` to enable this.
-- **Detekt report formats:** Detekt.kt line ~44 checks `project.findProperty("ci") == "true"` and disables html, txt, md reports in CI; only xml + sarif are required for GitHub reporting.
-- **KMP Smoke Compile:** Use `./gradlew kmpSmokeCompile` instead of listing individual module compile tasks. The `kmpSmokeCompile` lifecycle task (registered in `RootConventionPlugin`) auto-discovers all KMP modules and depends on their `compileKotlinJvm` + `compileKotlinIosSimulatorArm64` tasks.
-- **Robolectric SDK caching:** The `gradle-setup` composite action caches `~/.m2/repository/org/robolectric` to prevent flaky `SocketException` failures when Robolectric downloads instrumented SDK jars. The cache key is `robolectric-{version}-sdk{level}` — update it when bumping the Robolectric version in `libs.versions.toml` or the SDK level in `robolectric.properties` / `@Config(sdk = ...)`.
-- **`mavenLocal()` gated:** The `mavenLocal()` repository is disabled by default to prevent CI cache poisoning. For local JitPack testing, pass `-PuseMavenLocal` to Gradle.
-- **Terminal Pagers:** When running shell commands like `git diff` or `git log`, ALWAYS use `--no-pager` (e.g., `git --no-pager diff`) to prevent the agent from getting stuck in an interactive prompt.
-- **Text Search:** Prefer using `rg` (ripgrep) over `grep` or `find` for fast text searching across the codebase.
-
-### C. Documentation Sync
-`AGENTS.md` is the single source of truth for agent instructions. `.github/copilot-instructions.md` and `GEMINI.md` are thin stubs that redirect here — do NOT duplicate content into them.
-
-When you modify architecture, module targets, CI tasks, validation commands, or agent workflow rules, update `AGENTS.md`, `docs/agent-playbooks/*`, `docs/kmp-status.md`, and `docs/decisions/architecture-review-2026-03.md` as needed.
-
-## 5. Troubleshooting
-- **Build Failures:** Check `gradle/libs.versions.toml` for dependency conflicts.
-- **Missing Secrets:** Check `local.properties`.
-- **JDK Version:** JDK 21 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/GEMINI.md b/GEMINI.md
index 9076b718e..72a350afb 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,6 +1,6 @@
-# Meshtastic Android - Agent Guide
+# Meshtastic Android - Google Gemini Guide
-**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically.
+> **Note:** The canonical instructions for all AI Agents have been deduplicated.
-See [AGENTS.md](AGENTS.md) for architecture, conventions, execution protocol, and coding standards.
-See [docs/agent-playbooks/README.md](docs/agent-playbooks/README.md) for version baselines and task recipes.
+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/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/build.gradle.kts b/app/build.gradle.kts
index 77302534e..d239d0530 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -171,8 +171,6 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
- isMinifyEnabled = true
- isShrinkResources = true
isDebuggable = false
}
}
@@ -243,9 +241,10 @@ dependencies {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.material)
- implementation(libs.androidx.compose.material3)
- 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)
@@ -265,7 +264,6 @@ dependencies {
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,7 +279,6 @@ dependencies {
googleImplementation(libs.maps.compose)
googleImplementation(libs.maps.compose.utils)
googleImplementation(libs.maps.compose.widgets)
- 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)
@@ -297,12 +294,6 @@ 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)
@@ -310,7 +301,7 @@ dependencies {
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)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 995f659ba..de2b3144c 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,61 +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) ----------------
-# Networking
-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
-
-# Koin DI: prevent R8 from merging exception classes (observed as io.ktor.http.URLDecodeException
-# replacing Koin's InstanceCreationException in stack traces, making crashes undiagnosable).
--keep class org.koin.core.error.** { *; }
-
-# R8 optimization for Kotlin null checks (AGP 9.0+)
--processkotlinnullchecks remove
-
-# Compose Multiplatform resources: keep the resource library internals and generated Res
-# accessor classes so R8 does not tree-shake the resource loading infrastructure.
-# Without these rules the fdroid flavor (which has fewer transitive Compose dependencies
-# than google) crashes at startup with a misleading URLDecodeException due to R8
-# exception-class merging (see Koin keep rule above).
--keep class org.jetbrains.compose.resources.** { *; }
--keep class org.meshtastic.core.resources.** { *; }
-
-# 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/map/MapView.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
index 54935b422..b4d0e1bbd 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -77,8 +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.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.model.MarkerWithLabel
import org.meshtastic.core.common.gpsDisabled
@@ -130,6 +128,8 @@ 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.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
@@ -861,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))
}
}
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 04f896d18..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,20 +124,21 @@ 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 {
- Marker(this).apply {
- icon = navIcon
- rotation = ((it.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)
- setOnMarkerClickListener { _, _ ->
- onClick()
- true
+ val markers =
+ positions.map { pos ->
+ Marker(this).apply {
+ icon = navIcon
+ rotation = ((pos.ground_track ?: 0) * 1e-5).toFloat()
+ setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER)
+ position = GeoPoint((pos.latitude_i ?: 0) * 1e-7, (pos.longitude_i ?: 0) * 1e-7)
+ setOnMarkerClickListener { _, _ ->
+ onClick(pos.time)
+ true
+ }
}
}
- }
overlays.addAll(markers)
return markers
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
index 0178a498e..77b595d88 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -26,9 +26,17 @@ 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) {
+fun NodeTrackMap(
+ destNum: Int,
+ positions: List,
+ modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
+) {
val vm = koinViewModel()
vm.setDestNum(destNum)
NodeTrackOsmMap(
@@ -36,5 +44,7 @@ fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = M
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
index 64d207a6e..a6aec4c2d 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/node/NodeTrackOsmMap.kt
@@ -42,7 +42,6 @@ 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.component.MapControlsOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.core.common.util.nowSeconds
@@ -50,6 +49,7 @@ 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
@@ -61,8 +61,10 @@ import kotlin.math.roundToInt
*
* 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.app.map.component.MapControlsOverlay] with a track time filter slider so
- * users can adjust the time range directly from the map.
+ * 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.
@@ -73,6 +75,8 @@ fun NodeTrackOsmMap(
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
+ selectedPositionTime: Int? = null,
+ onPositionSelected: ((Int) -> Unit)? = null,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
@@ -109,7 +113,15 @@ fun NodeTrackOsmMap(
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
- map.addPositionMarkers(filteredPositions) {}
+ 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)
+ }
+ }
},
)
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 bf42494e5..0583dd78e 100644
--- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -26,7 +26,6 @@ import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.datadog.android.Datadog
import com.datadog.android.DatadogSite
-import com.datadog.android.compose.enableComposeActionTracking
import com.datadog.android.core.configuration.Configuration
import com.datadog.android.log.Logger
import com.datadog.android.log.Logs
@@ -160,7 +159,6 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic
.trackFrustrations(false) // Disable click-tracking based frustration detection
.trackLongTasks()
.trackNonFatalAnrs(true)
- .enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags
.setSessionSampleRate(sampleRate)
.build()
Rum.enable(rumConfiguration)
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 0418d76b7..c8f2f3fee 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -33,6 +33,7 @@ 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.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -96,8 +97,6 @@ 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.MapButton
-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
@@ -136,6 +135,8 @@ 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.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
@@ -155,7 +156,12 @@ sealed interface GoogleMapMode {
data object Main : GoogleMapMode
/** Focused node position track: polyline + gradient markers for historical positions. */
- data class NodeTrack(val focusedNode: Node?, val positions: List) : GoogleMapMode
+ 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(
@@ -424,6 +430,17 @@ fun MapView(
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) {
@@ -577,6 +594,8 @@ fun MapView(
sortedPositions = sortedTrackPositions,
displayUnits = displayUnits,
myNodeNum = myNodeNum,
+ selectedPositionTime = mode.selectedPositionTime,
+ onPositionSelected = mode.onPositionSelected,
)
}
}
@@ -808,17 +827,24 @@ private fun MainMapContent(
* 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
sortedPositions.forEachIndexed { index, position ->
key(position.time) {
@@ -829,13 +855,23 @@ private fun NodeTrackOverlay(
} else {
1f
}
- val color = Color(focusedNode.colors.second).copy(alpha = alpha)
+ val isSelected = position.time == selectedPositionTime
+ val color =
+ if (isSelected) {
+ selectedColor
+ } else {
+ Color(focusedNode.colors.second).copy(alpha = alpha)
+ }
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)
}
@@ -844,13 +880,18 @@ private fun NodeTrackOverlay(
state = markerState,
title = stringResource(Res.string.position),
snippet = formatAgo(position.time),
- zIndex = 1f + alpha,
+ 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,
)
}
}
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 70ff4858d..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,6 +49,7 @@ 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.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@@ -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,
@@ -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) {
@@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
- val loadedItems = persistedLayerFiles.mapNotNull { file ->
- if (file.isFile) {
- val layerType =
- when (file.extension.lowercase()) {
- "kml",
- "kmz",
- -> LayerType.KML
- "geojson",
- "json",
- -> LayerType.GEOJSON
- else -> null
- }
+ val loadedItems =
+ persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ val layerType =
+ when (file.extension.lowercase()) {
+ "kml",
+ "kmz",
+ -> LayerType.KML
+ "geojson",
+ "json",
+ -> LayerType.GEOJSON
+ else -> null
+ }
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
+ layerType?.let {
+ val uri = Uri.fromFile(file)
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = it,
+ )
+ }
+ } else {
+ null
}
- } else {
- null
}
- }
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@@ -550,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")
@@ -621,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()) {
@@ -636,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 fb5f682ed..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
@@ -31,6 +31,7 @@ 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
@@ -125,7 +126,10 @@ fun CustomMapLayersSheet(
}
}
}
- IconButton(onClick = { onToggleVisibility(layer.id) }) {
+ IconToggleButton(
+ checked = layer.isVisible,
+ onCheckedChange = { onToggleVisibility(layer.id) },
+ ) {
Icon(
imageVector =
if (layer.isVisible) {
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
index 513957c61..2f7244b97 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/node/NodeTrackMap.kt
@@ -31,11 +31,28 @@ import org.meshtastic.proto.Position
* [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) {
+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))
+ 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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 43468c69d..f7d2ce900 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -288,7 +288,7 @@
+ android:resource="@xml/widget_local_stats_info" />
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index 4d74c2b5a..ffdb465d6 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,12 +24,19 @@
}
],
"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 to be available on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- T5-4.7-S3 Epaper Pro support by @mverch67 in https://github.com/meshtastic/firmware/pull/6625\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- Add new configuration files for LR11xx variants by @NomDeTom in https://github.com/meshtastic/firmware/pull/9761\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- Align 920–925 MHz limits as per NBTC regulations in Thailand (27 dBm, 10% duty cycle) by @hereismeaw in https://github.com/meshtastic/firmware/pull/9827\r\n- Add APIPort to native config by @pdxlocations in https://github.com/meshtastic/firmware/pull/9840\r\n- T-mini Eink S3 Support for both InkHUD and BaseUI by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9856\r\n- Consolidate SHTs into one class by @oscgonfer in https://github.com/meshtastic/firmware/pull/9859\r\n- Experiment: C++17 support by @thebentern in https://github.com/meshtastic/firmware/pull/9874\r\n- Remove a bunch of warnings in SEN5X by @oscgonfer in https://github.com/meshtastic/firmware/pull/9884\r\n- BaseUI: Emote Refactoring by @HarukiToreda in https://github.com/meshtastic/firmware/pull/9896\r\n- Add spoof detection for UDP packets in UdpMulticastHandler by @NomDeTom in https://github.com/meshtastic/firmware/pull/9905\r\n- Heltec v4.3: enable LNA by default by @weebl2000 in https://github.com/meshtastic/firmware/pull/9906\r\n- Heltec V4 + TFT expansion kit: rotated MUI by @mverch67 in https://github.com/meshtastic/firmware/pull/9938\r\n- HexDump: Add const to the buf parameter in hexDump. by @fw190d13 in https://github.com/meshtastic/firmware/pull/9944\r\n- Add meshtasticd config metadata by @vidplace7 in https://github.com/meshtastic/firmware/pull/10001\r\n- Exclude accelerometer on new MESHTASTIC_EXCLUDE_ACCELEROMETER flag by @thebentern in https://github.com/meshtastic/firmware/pull/10004\r\n- MUI: WiFi map tile download: heltec V4 adaptations by @mverch67 in https://github.com/meshtastic/firmware/pull/10011\r\n- Mesh-tab wifi map + exclude screen fix by @mverch67 in https://github.com/meshtastic/firmware/pull/10038\r\n- Thinknode_m5 minor fixes by @jp-bennett in https://github.com/meshtastic/firmware/pull/10049\r\n- Add a hardfault handler so it's more obvious when STM32 crashes. by @Stary2001 in https://github.com/meshtastic/firmware/pull/10071\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- Improved manual build flow to make it easier by @NomDeTom in https://github.com/meshtastic/firmware/pull/8839\r\n- Support mini ePaper S3 Kit by @mverch67 in https://github.com/meshtastic/firmware/pull/9335\r\n- Remove GPS Baudrate locking for Seeed Xiao S3 Kit by @fifieldt in https://github.com/meshtastic/firmware/pull/9374\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 RAK4631 Ethernet gateway API connection loss after W5100S brownout by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9754\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- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients by @PhilipLykov in https://github.com/meshtastic/firmware/pull/9770\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- Traceroute through MQTT misses uplink node if MQTT is encrypted by @domusonline in https://github.com/meshtastic/firmware/pull/9798\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- Debian: Extend sourcedeb cache expiration by @vidplace7 in https://github.com/meshtastic/firmware/pull/9858\r\n- Fix(tlora-pager): Remove SDCARD_USE_SPI1 so SX1262 and SD can share bus by @ndoo in https://github.com/meshtastic/firmware/pull/9870\r\n- Update ESP8266Audio dependency to Meshtastic fork for compatibility by @thebentern in https://github.com/meshtastic/firmware/pull/9872\r\n- Pioarduino Heltec v4: fix build due to LED_BUILTIN compile error. by @cpatulea in https://github.com/meshtastic/firmware/pull/9875\r\n- Fix rak_wismeshtag low‑voltage reboot hang after App configuration by @Ethan-chen1234-zy in https://github.com/meshtastic/firmware/pull/9897\r\n- Fix for preserving pki_encrypted and public_key when relaying UDP multicast packets to radio. by @niklaswall in https://github.com/meshtastic/firmware/pull/9916\r\n- Add new RAK 13302 power curve by @jp-bennett in https://github.com/meshtastic/firmware/pull/9929\r\n- MQTT settings silently fail to persist when broker is unreachable by @rcatal01 in https://github.com/meshtastic/firmware/pull/9934\r\n- Remove early return during scan of BME address for BMP sensors by @NomDeTom in https://github.com/meshtastic/firmware/pull/9935\r\n- Ensure infrastructure role-based minimums are coerced since they don't have scaling by @thebentern in https://github.com/meshtastic/firmware/pull/9937\r\n- Fixes #9792 : Hop with Meshtastic ffff and ?dB is added to missing hop in traceroute by @domusonline in https://github.com/meshtastic/firmware/pull/9945\r\n- Fix NodeInfo suppression logic to ensure suppression only applies to external requests by @thebentern in https://github.com/meshtastic/firmware/pull/9947\r\n- Enable touch-to-backlight on T-Echo (not just T-Echo Plus) by @okturan in https://github.com/meshtastic/firmware/pull/9953\r\n- Fix TFTDisplay::display to align pixels at 32-bit boundary by @notmasteryet in https://github.com/meshtastic/firmware/pull/9956\r\n- Fix(routing): prevent licensed users from rebroadcasting packets to or from unlicensed users by @NomDeTom in https://github.com/meshtastic/firmware/pull/9958\r\n- Add heltec_mesh_node_t096 board. by @Quency-D in https://github.com/meshtastic/firmware/pull/9960\r\n- Cardputer-Adv I2S sound by @mverch67 in https://github.com/meshtastic/firmware/pull/9963\r\n- Fixes #9850: Double space issue with Cyrillic OLED font by @dev-nightcore in https://github.com/meshtastic/firmware/pull/9971\r\n- Add LED_BUILTIN for variant tlora_v1 by @RobertSasak in https://github.com/meshtastic/firmware/pull/9973\r\n- Add timeout to PPA uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/9989\r\n- Exclude web server, paxcounter and few others from original ESP32 generation to fix IRAM overflow by @thebentern in https://github.com/meshtastic/firmware/pull/10005\r\n- Update External Notifications with a full redo of logic gates by @Xaositek in https://github.com/meshtastic/firmware/pull/10006\r\n- Supporting STM32WL is like squeezing blood from a stone by @Stary2001 in https://github.com/meshtastic/firmware/pull/10015\r\n- Configure NFC pins as GPIO for older bootloaders by @NomDeTom in https://github.com/meshtastic/firmware/pull/10016\r\n- Fix TransmitHistory to improve epoch handling by @thebentern in https://github.com/meshtastic/firmware/pull/10017\r\n- Wio-sdk-wm1110: inherit build_unflags by @vidplace7 in https://github.com/meshtastic/firmware/pull/10034\r\n- ESP32: Take away \"tbeam\" boards PSRAM to reclaim iram by @vidplace7 in https://github.com/meshtastic/firmware/pull/10036\r\n- Set t5s3_epaper_inkhud to `extra` by @vidplace7 in https://github.com/meshtastic/firmware/pull/10037\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- Update meshtastic-esp32_https_server digest to b78f12c by @app/renovate in https://github.com/meshtastic/firmware/pull/9851\r\n- Update meshtastic/device-ui digest to 622b034 by @app/renovate in https://github.com/meshtastic/firmware/pull/9864\r\n- Update GxEPD2 to v1.6.8 by @app/renovate in https://github.com/meshtastic/firmware/pull/9918\r\n- Update pnpm/action-setup action to v5 by @app/renovate in https://github.com/meshtastic/firmware/pull/9926\r\n- Update meshtastic/device-ui digest to f36d2a9 by @app/renovate in https://github.com/meshtastic/firmware/pull/9940\r\n- Update dorny/test-reporter action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/9981\r\n- Deps: Cleanup LewisHe library references by @vidplace7 in https://github.com/meshtastic/firmware/pull/10007\r\n- Dependencies: Remove all fuzzy-matches, spot-add renovate by @vidplace7 in https://github.com/meshtastic/firmware/pull/10008\r\n- Update Adafruit_BME680 to v2.0.6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10009\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10023\r\n- Renovate: Don't update branches outside the schedule (daily) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10039\r\n- Update meshtastic/device-ui digest to 7b1485b by @app/renovate in https://github.com/meshtastic/firmware/pull/10044\r\n- Update meshtastic/device-ui digest to 1897dd1 by @app/renovate in https://github.com/meshtastic/firmware/pull/10050\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23"
+ "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",
@@ -177,22 +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"
}
]
},
- "pullRequests": [
- {
- "id": "9999",
- "title": "Use UDP as roof node <---> indoor nodes backchannel",
- "page_url": "https://github.com/meshtastic/firmware/pull/9999",
- "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 342b845dd..628865010 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -45,11 +45,12 @@ 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
@@ -57,8 +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.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
@@ -91,6 +92,8 @@ 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.
@@ -124,6 +127,8 @@ class MainActivity : ComponentActivity() {
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) {
@@ -141,7 +146,7 @@ class MainActivity : ComponentActivity() {
}
AppCompositionLocals {
- AppTheme(dynamicColor = dynamic, darkTheme = dark) {
+ AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
@@ -164,6 +169,16 @@ 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(
@@ -175,8 +190,14 @@ class MainActivity : ComponentActivity() {
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
- { destNum, positions, modifier ->
- org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
+ { destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
+ org.meshtastic.app.map.node.NodeTrackMap(
+ destNum,
+ positions,
+ modifier,
+ selectedPositionTime,
+ onPositionSelected,
+ )
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides
@@ -249,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()
}
@@ -270,7 +296,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- model.handleDeepLink(uri.toMeshtasticUri()) { 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/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
similarity index 67%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
rename to app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
index 7ca9f9fe8..04f0350c8 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
@@ -14,16 +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 kotlin.test.Test
-import kotlin.test.assertEquals
+import org.koin.core.annotation.KoinApplication
-class MeshtasticUriTest {
- @Test
- fun testParseAndToString() {
- val uriString = "content://com.example.provider/file.txt"
- val uri = MeshtasticUri.parse(uriString)
- assertEquals(uriString, uri.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/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 7f6fb0215..91ab81ec0 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -24,6 +24,8 @@ import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
+import coil3.memoryCacheMaxSizePercentWhileInBackground
+import coil3.network.DeDupeConcurrentRequestStrategy
import coil3.network.ktor3.KtorNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
@@ -31,18 +33,25 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
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 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 {
@@ -63,7 +72,12 @@ class NetworkModule {
buildConfigProvider: BuildConfigProvider,
): ImageLoader = ImageLoader.Builder(context = application)
.components {
- add(KtorNetworkFetcherFactory(httpClient = httpClient))
+ add(
+ KtorNetworkFetcherFactory(
+ httpClient = httpClient,
+ concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
+ ),
+ )
add(SvgDecoder.Factory(scaleToDensity = true))
}
.memoryCache {
@@ -76,6 +90,7 @@ class NetworkModule {
.build()
}
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
+ .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
.crossfade(enable = true)
.build()
@@ -83,8 +98,21 @@ class NetworkModule {
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) { level = LogLevel.BODY }
+ install(plugin = Logging) {
+ logger = KermitHttpLogger
+ level = LogLevel.BODY
+ }
}
}
}
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 7b140cca8..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,6 +25,7 @@ import androidx.work.WorkerParameters
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import kotlinx.coroutines.CoroutineDispatcher
+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
@@ -60,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 8f262c47c..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
@@ -37,7 +36,7 @@ class FakeMeshServiceNotifications : MeshServiceNotifications {
override fun updateServiceStateNotification(
state: org.meshtastic.core.model.ConnectionState,
telemetry: Telemetry?,
- ): Notification = mock(MockMode.autofill)
+ ) {}
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 0665d50db..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,12 +16,12 @@
*/
package org.meshtastic.app.ui
-import androidx.compose.ui.test.junit4.v2.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.NodesRoute
@@ -35,15 +35,14 @@ 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 {
+ fun verifyNavigationGraphsAssembleWithoutCrashing() = runComposeUiTest {
+ setContent {
val backStack = rememberNavBackStack(NodesRoute.NodesGraph)
entryProvider {
contactsGraph(backStack, emptyFlow())
diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts
index faaeb9f68..71823c763 100644
--- a/build-logic/convention/build.gradle.kts
+++ b/build-logic/convention/build.gradle.kts
@@ -54,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)
diff --git a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
index 046e3c4aa..16166a776 100644
--- a/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AnalyticsConventionPlugin.kt
@@ -18,7 +18,7 @@ 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.InjectBuildIdToAssetsTask
-import com.datadog.gradle.plugin.InstrumentationMode
+
import com.datadog.gradle.plugin.SdkCheckLevel
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -110,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/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
index fd432a1fa..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,16 +36,8 @@ class AndroidApplicationConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
-
- defaultConfig {
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- vectorDrawables.useSupportLibrary = true
- }
- testOptions {
- animationsDisabled = true
- unitTests.isReturnDefaultValues = true
- }
+ defaultConfig { vectorDrawables.useSupportLibrary = true }
buildTypes {
getByName("release") {
@@ -55,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") {
@@ -67,9 +58,7 @@ class AndroidApplicationConventionPlugin : Plugin {
}
}
- buildFeatures {
- buildConfig = true
- }
+ buildFeatures { buildConfig = true }
}
configureTestOptions()
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
index cf3ae81db..68771d24a 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -38,11 +38,6 @@ class AndroidLibraryConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
- defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- testOptions {
- animationsDisabled = true
- unitTests.isReturnDefaultValues = true
- }
defaultConfig {
// When flavorless modules depend on flavored modules (like :core:data),
diff --git a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
index 4d02a630a..be280f29c 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -42,6 +42,7 @@ class KmpFeatureConventionPlugin : Plugin {
extensions.configure {
sourceSets.getByName("commonMain").dependencies {
// Compose Multiplatform UI
+ implementation(libs.library("compose-multiplatform-animation"))
implementation(libs.library("compose-multiplatform-material3"))
// Lifecycle & ViewModel (JetBrains KMP forks — safe in commonMain)
@@ -53,19 +54,18 @@ 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-ui-text"))
- implementation(libs.library("androidx-compose-ui-tooling-preview"))
+ implementation(libs.library("compose-multiplatform-ui"))
}
sourceSets.getByName("commonTest").dependencies { implementation(project(":core:testing")) }
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 a1a111a64..540834ef5 100644
--- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
@@ -14,11 +14,9 @@
* 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
@@ -39,8 +37,6 @@ class KmpLibraryConventionPlugin : Plugin {
apply(plugin = "org.gradle.test-retry")
apply(plugin = libs.plugin("mokkery").get().pluginId)
- extensions.configure { stubs.allowConcreteClassInstantiation.set(true) }
-
configureKotlinMultiplatform()
configureKmpTestDependencies()
configureTestOptions()
diff --git a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
index 9b832ce16..b4f2acfbe 100644
--- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
@@ -29,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/org/meshtastic/buildlogic/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidCompose.kt
index 40cbe83fa..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
@@ -24,18 +24,46 @@ import org.gradle.kotlin.dsl.dependencies
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))
- }
- "debugImplementation"(libs.library("androidx-compose-ui-tooling"))
- "implementation"(libs.library("androidx-compose-runtime"))
+ "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-runtime"))
"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/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/KotlinAndroid.kt
index 580db4c4b..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
@@ -44,19 +44,19 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
compileSdk = compileSdkVersion
defaultConfig.minSdk = minSdkVersion
+ defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (this is ApplicationExtension) {
defaultConfig.targetSdk = targetSdkVersion
}
- val javaVersion = if (project.name in listOf("api", "model", "proto")) {
- JavaVersion.VERSION_17
- } else {
- JavaVersion.VERSION_21
- }
+ 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(
@@ -72,6 +72,23 @@ internal fun Project.configureKotlinAndroid(commonExtension: CommonExtension) {
/** 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()
@@ -190,11 +207,25 @@ internal fun Project.configureKotlinJvm() {
configureKotlin()
}
+/** 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 {
- val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
- val isPublishedModule = project.name in listOf("api", "model", "proto")
+ 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)
@@ -208,14 +239,7 @@ private inline fun Project.configureKotlin() {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
- freeCompilerArgs.addAll(
- "-opt-in=kotlin.uuid.ExperimentalUuidApi",
- "-opt-in=kotlin.time.ExperimentalTime",
- "-Xexpect-actual-classes",
- "-Xcontext-parameters",
- "-Xannotation-default-target=param-property",
- "-Xskip-prerelease-check",
- )
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
@@ -230,21 +254,13 @@ private inline fun Project.configureKotlin() {
tasks.withType().configureEach {
compilerOptions {
- val isPublishedModule = project.name in listOf("api", "model", "proto")
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(
- "-opt-in=kotlin.uuid.ExperimentalUuidApi",
- "-opt-in=kotlin.time.ExperimentalTime",
- "-Xexpect-actual-classes",
- "-Xcontext-parameters",
- "-Xannotation-default-target=param-property",
- "-Xskip-prerelease-check",
- "-jvm-default=no-compatibility",
- )
+ freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
+ freeCompilerArgs.add("-jvm-default=no-compatibility")
}
}
}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 2fa797c74..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.4.0")
+ id("com.gradle.develocity") version("4.4.1")
}
dependencyResolutionManagement {
diff --git a/codecov.yml b/codecov.yml
index 6e0989227..7f77510ff 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -57,10 +57,6 @@ component_management:
name: Desktop
paths:
- desktop/**
- - component_id: example
- name: Example
- paths:
- - mesh_service_example/**
ignore:
- "**/build/**"
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/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 edfac5083..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)
-- 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 75237887b..000000000
--- a/conductor/tech-stack.md
+++ /dev/null
@@ -1,38 +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 platform-appropriate SQLite drivers (e.g., `BundledSQLiteDriver` for JVM/Desktop, Framework driver for Android).
-- **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`.
-- **Platform-Specific Verification:** Use **Robolectric** on the Android host target to verify KMP modules that interact with Android framework components (like `Uri` or `Room`).
-- **Subclassing Pattern:** Maintain a unified test suite by defining abstract base tests in `commonTest` and platform-specific subclasses in `jvmTest` and `androidHostTest` for initialization (e.g., calling `setupTestContext()`).
-- **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/barcode/build.gradle.kts b/core/barcode/build.gradle.kts
index c2533dd3c..711cccc09 100644
--- a/core/barcode/build.gradle.kts
+++ b/core/barcode/build.gradle.kts
@@ -33,9 +33,9 @@ dependencies {
implementation(projects.core.ui)
implementation(libs.androidx.activity.compose)
- implementation(libs.androidx.compose.material3)
- 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)
@@ -52,6 +52,6 @@ dependencies {
testImplementation(libs.junit)
testRuntimeOnly(libs.junit.vintage.engine)
testImplementation(libs.robolectric)
- testImplementation(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/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt b/core/barcode/src/test/kotlin/org/meshtastic/core/barcode/BarcodeScannerTest.kt
index e06562cfb..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.v2.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/build.gradle.kts b/core/ble/build.gradle.kts
index b61fad0e7..f270e6aa3 100644
--- a/core/ble/build.gradle.kts
+++ b/core/ble/build.gradle.kts
@@ -46,13 +46,9 @@ kotlin {
implementation(libs.jetbrains.lifecycle.runtime)
}
- commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.androidx.lifecycle.testing)
- }
+ commonTest.dependencies {
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(projects.core.testing)
}
}
}
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 c8d444688..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,6 +31,7 @@ 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
@@ -49,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() }
@@ -86,7 +87,7 @@ class AndroidBluetoothRepository(
return
}
- kotlinx.coroutines.suspendCancellableCoroutine { cont ->
+ suspendCancellableCoroutine { cont ->
val receiver =
object : android.content.BroadcastReceiver() {
@SuppressLint("MissingPermission")
@@ -180,14 +181,15 @@ class AndroidBluetoothRepository(
// user renamed the device in firmware since the cache was populated.
deviceCache.keys.retainAll(bondedAddresses)
return bonded.map { device ->
- deviceCache
- .getOrPut(device.address) { DirectBleDevice(device.address, device.name) }
- .also { cached ->
- // Refresh name if it changed (firmware rename, etc.)
- if (cached.name != device.name) {
- deviceCache[device.address] = DirectBleDevice(device.address, device.name)
- }
- }
+ 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
+ }
}
}
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 e9928f8d5..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
@@ -20,15 +20,29 @@ 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.
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 1bfaff648..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
@@ -19,14 +19,17 @@ 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.
*
- * Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers.
+ * [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 {
- @Volatile var activePeripheral: Peripheral? = null
-
- @Volatile 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 06496aeea..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
@@ -19,6 +19,7 @@ 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
@@ -49,8 +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): 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()
@@ -77,6 +78,17 @@ interface BleService {
/** 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
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/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 5265127c1..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
@@ -18,9 +18,11 @@ 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
@@ -30,7 +32,6 @@ 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
@@ -39,6 +40,7 @@ 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
/** [BleService] implementation backed by a Kable [Peripheral] for a specific GATT service. */
@@ -50,6 +52,9 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
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))
@@ -78,8 +83,11 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
/**
* [BleConnection] implementation using Kable for cross-platform BLE communication.
*
- * Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking,
- * and GATT service profile access.
+ * 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 {
@@ -88,10 +96,8 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
private var connectionScope: CoroutineScope? = null
companion object {
- private const val INITIAL_RETRY_DELAY_MS = 1000L
- private const val MAX_RETRY_DELAY_MS = 30_000L
- private const val MAX_CONNECT_RETRIES = 15
- private const val BACKOFF_MULTIPLIER = 2
+ /** 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)
@@ -108,47 +114,32 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
)
override val connectionState: SharedFlow = _connectionState.asSharedFlow()
- @Suppress("LongMethod", "CyclomaticComplexMethod")
+ @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 ->
- Logger.w(cause) { "[${device.address}] Observation failure suppressed" }
- }
- platformConfig(device) { autoConnect.value }
- }
- is DirectBleDevice ->
- createPeripheral(device.address) {
- observationExceptionHandler { cause ->
- 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() }
- // Clean up previous peripheral under NonCancellable to prevent GATT resource leaks
- // if the calling coroutine is cancelled during teardown.
- withContext(NonCancellable) {
- try {
- peripheral?.disconnect()
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[${device.address}] Failed to disconnect previous peripheral" }
- }
- try {
- peripheral?.close()
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[${device.address}] Failed to close previous peripheral" }
- }
- }
+ cleanUpPeripheral(device.address)
peripheral = p
- ActiveBleConnection.activePeripheral = p
- ActiveBleConnection.activeAddress = device.address
+ ActiveBleConnection.active = ActiveConnection(p, device.address)
_deviceFlow.emit(device)
@@ -162,21 +153,15 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
hasStartedConnecting = true
}
- when (device) {
- is KableBleDevice -> device.updateState(mappedState)
- is DirectBleDevice -> device.updateState(mappedState)
- }
+ meshtasticDevice.updateState(mappedState)
_connectionState.emit(mappedState)
}
.launchIn(scope)
- var retryCount = 0
- var retryDelayMs = INITIAL_RETRY_DELAY_MS
while (p.state.value !is State.Connected) {
- autoConnect.value =
+ autoConnect =
try {
- // Cancel any previous connectionScope to avoid leaking the old coroutine scope.
connectionScope?.let { oldScope ->
Logger.d { "[${device.address}] Cancelling previous connectionScope before reconnect" }
oldScope.coroutineContext.job.cancel()
@@ -185,52 +170,50 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
false
} catch (e: CancellationException) {
throw e
- } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") _: Exception) {
- retryCount++
- if (retryCount > MAX_CONNECT_RETRIES) {
- Logger.w { "[${device.address}] Max connect retries ($MAX_CONNECT_RETRIES) exceeded" }
- _connectionState.emit(BleConnectionState.Disconnected)
- return
+ } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception) {
+ 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}] Connect retry $retryCount, backoff ${retryDelayMs}ms" }
- delay(retryDelayMs)
- retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS)
+ 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): BleConnectionState = try {
- withTimeout(timeoutMs) {
+ 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
+ BleConnectionState.Disconnected(DisconnectReason.Timeout)
} catch (e: CancellationException) {
// External cancellation (scope closed) — must propagate.
throw e
} catch (_: Exception) {
- BleConnectionState.Disconnected
+ 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)
+ _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)
}
@@ -247,4 +230,29 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
}
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 d0f3a7168..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 {
+ /**
+ * 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/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
index d9e27704f..5e91b3459 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.ble
import com.juul.kable.Scanner
+import com.juul.kable.logs.Logging
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withTimeoutOrNull
@@ -28,6 +29,10 @@ import kotlin.uuid.Uuid
class KableBleScanner : BleScanner {
override fun scan(timeout: Duration, serviceUuid: Uuid?, address: String?): Flow {
val scanner = Scanner {
+ logging {
+ engine = KermitLogEngine
+ level = Logging.Level.Events
+ }
// Use separate match blocks so each filter is evaluated independently (OR semantics).
// Combining address and service UUID in a single match{} creates an AND filter which
// silently drops results on OEM stacks (Samsung, Xiaomi) when the device uses a
@@ -43,7 +48,15 @@ class KableBleScanner : BleScanner {
// By wrapping it in a channelFlow with a timeout, we enforce the BleScanner contract cleanly.
return channelFlow {
withTimeoutOrNull(timeout) {
- scanner.advertisements.collect { advertisement -> send(KableBleDevice(advertisement)) }
+ scanner.advertisements.collect { advertisement ->
+ send(
+ MeshtasticBleDevice(
+ address = advertisement.identifier.toString(),
+ name = advertisement.name,
+ advertisement = advertisement,
+ ),
+ )
+ }
}
}
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt
index 46ace854f..3f0e61864 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfile.kt
@@ -18,110 +18,101 @@ package org.meshtastic.core.ble
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMNUM_CHARACTERISTIC
-import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIOSYNC_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.LOGRADIO_CHARACTERISTIC
import org.meshtastic.core.ble.MeshtasticBleConstants.TORADIO_CHARACTERISTIC
+import kotlin.time.Duration.Companion.milliseconds
/**
* [MeshtasticRadioProfile] implementation using Kable BLE characteristics.
*
- * Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` +
- * `FROMRADIO` polling fallback for older firmware versions.
+ * Uses the standard Meshtastic BLE protocol: FROMNUM notifications trigger polling reads on the FROMRADIO
+ * characteristic. The firmware gates FROMNUM notifications behind `STATE_SEND_PACKETS`, so during the config handshake
+ * we seed the drain trigger to poll proactively.
*/
class KableMeshtasticRadioProfile(private val service: BleService) : MeshtasticRadioProfile {
private val toRadio = service.characteristic(TORADIO_CHARACTERISTIC)
private val fromRadioChar = service.characteristic(FROMRADIO_CHARACTERISTIC)
- private val fromRadioSync = service.characteristic(FROMRADIOSYNC_CHARACTERISTIC)
private val fromNum = service.characteristic(FROMNUM_CHARACTERISTIC)
private val logRadioChar = service.characteristic(LOGRADIO_CHARACTERISTIC)
companion object {
- private const val TRANSIENT_RETRY_DELAY_MS = 500L
+ private val TRANSIENT_RETRY_DELAY = 500.milliseconds
}
- // replay = 1: a seed emission placed here before the collector starts is replayed to the
- // collector immediately on subscription. This is what drives the initial FROMRADIO poll
- // during the config-handshake phase, where the firmware suppresses FROMNUM notifications
- // (it only emits them in STATE_SEND_PACKETS). Without the initial replay the entire config
- // stream would be silently skipped on devices that lack FROMRADIOSYNC.
+ private val subscriptionReady = CompletableDeferred()
+
+ /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */
private val triggerDrain =
MutableSharedFlow(replay = 1, extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
- // Using observe() for fromRadioSync or legacy read loop for fromRadio
@Suppress("TooGenericExceptionCaught", "SwallowedException")
override val fromRadio: Flow = channelFlow {
- // Try to observe FROMRADIOSYNC if available. If it fails, fallback to FROMNUM/FROMRADIO.
- // This mirrors the robust fallback logic originally established in the legacy Android Nordic implementation.
launch {
- try {
- if (service.hasCharacteristic(fromRadioSync)) {
- service.observe(fromRadioSync).collect { send(it) }
- } else {
- error("fromRadioSync missing")
- }
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- // Fallback to legacy FROMNUM/FROMRADIO polling.
- // Wire up FROMNUM notifications for steady-state packet delivery.
- launch {
- if (service.hasCharacteristic(fromNum)) {
- service.observe(fromNum).collect { triggerDrain.tryEmit(Unit) }
+ if (service.hasCharacteristic(fromNum)) {
+ service
+ .observe(fromNum) {
+ Logger.d { "FROMNUM CCCD written — notifications enabled" }
+ subscriptionReady.complete(Unit)
}
- }
- // Seed the replay buffer so the collector below starts draining immediately.
- // The firmware does NOT send FROMNUM notifications during the config handshake
- // (it gates them on STATE_SEND_PACKETS). Without this seed the entire config
- // stream would never be read on devices that lack FROMRADIOSYNC.
- triggerDrain.tryEmit(Unit)
- triggerDrain.collect {
- var keepReading = true
- while (keepReading) {
- try {
- if (!service.hasCharacteristic(fromRadioChar)) {
- keepReading = false
- continue
- }
- val packet = service.read(fromRadioChar)
- if (packet.isEmpty()) keepReading = false else send(packet)
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
- keepReading = false
- // Don't permanently stop — the next triggerDrain emission will retry.
- delay(TRANSIENT_RETRY_DELAY_MS)
- }
+ .collect { triggerDrain.tryEmit(Unit) }
+ } else {
+ subscriptionReady.complete(Unit)
+ }
+ }
+ triggerDrain.tryEmit(Unit)
+ triggerDrain.collect {
+ var keepReading = true
+ while (keepReading) {
+ try {
+ if (!service.hasCharacteristic(fromRadioChar)) {
+ keepReading = false
+ continue
}
+ val packet = service.read(fromRadioChar)
+ if (packet.isEmpty()) keepReading = false else send(packet)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Logger.w(e) { "FROMRADIO read error, pausing before next drain trigger" }
+ keepReading = false
+ delay(TRANSIENT_RETRY_DELAY)
}
}
}
}
- @Suppress("TooGenericExceptionCaught", "SwallowedException")
- override val logRadio: Flow = channelFlow {
- try {
- if (service.hasCharacteristic(logRadioChar)) {
- service.observe(logRadioChar).collect { send(it) }
+ override val logRadio: Flow =
+ if (service.hasCharacteristic(logRadioChar)) {
+ service.observe(logRadioChar).catch { e ->
+ if (e is CancellationException) throw e
+ // logRadio is optional — swallow observation errors silently.
}
- } catch (e: CancellationException) {
- throw e
- } catch (_: Exception) {
- // logRadio is optional, ignore if not found
+ } else {
+ emptyFlow()
}
- }
override suspend fun sendToRadio(packet: ByteArray) {
service.write(toRadio, packet, service.preferredWriteType(toRadio))
triggerDrain.tryEmit(Unit)
}
+
+ override fun requestDrain() {
+ triggerDrain.tryEmit(Unit)
+ }
+
+ override suspend fun awaitSubscriptionReady() {
+ subscriptionReady.await()
+ }
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt
index 7a03a3d89..4bd395dc5 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableStateMapping.kt
@@ -25,14 +25,33 @@ import com.juul.kable.State
* state emitted by StateFlow upon subscription.
* @return the mapped [BleConnectionState], or null if the state should be ignored.
*/
-fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? {
- return when (this) {
- is State.Connecting -> BleConnectionState.Connecting
- is State.Connected -> BleConnectionState.Connected
- is State.Disconnecting -> BleConnectionState.Disconnecting
- is State.Disconnected -> {
- if (!hasStartedConnecting) return null
- BleConnectionState.Disconnected
- }
- }
+fun State.toBleConnectionState(hasStartedConnecting: Boolean): BleConnectionState? = when (this) {
+ is State.Connecting -> BleConnectionState.Connecting
+ is State.Connected -> BleConnectionState.Connected
+ is State.Disconnecting -> BleConnectionState.Disconnecting
+ is State.Disconnected ->
+ if (hasStartedConnecting) BleConnectionState.Disconnected(status.toDisconnectReason()) else null
+}
+
+/**
+ * Maps Kable's [State.Disconnected.Status] to [DisconnectReason].
+ *
+ * Groups platform-specific GATT/CBError codes into broad categories that the reconnect logic can act on without leaking
+ * platform details.
+ */
+fun State.Disconnected.Status?.toDisconnectReason(): DisconnectReason = when (this) {
+ null -> DisconnectReason.Unknown
+ State.Disconnected.Status.CentralDisconnected -> DisconnectReason.LocalDisconnect
+ State.Disconnected.Status.PeripheralDisconnected -> DisconnectReason.RemoteDisconnect
+ State.Disconnected.Status.Failed,
+ State.Disconnected.Status.L2CapFailure,
+ -> DisconnectReason.ConnectionFailed
+ State.Disconnected.Status.Timeout,
+ State.Disconnected.Status.LinkManagerProtocolTimeout,
+ -> DisconnectReason.Timeout
+ State.Disconnected.Status.Cancelled -> DisconnectReason.Cancelled
+ State.Disconnected.Status.EncryptionTimedOut -> DisconnectReason.EncryptionFailed
+ State.Disconnected.Status.ConnectionLimitReached -> DisconnectReason.ConnectionFailed
+ State.Disconnected.Status.UnknownDevice -> DisconnectReason.ConnectionFailed
+ is State.Disconnected.Status.Unknown -> DisconnectReason.PlatformSpecific(status)
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt
new file mode 100644
index 000000000..6884dc9e1
--- /dev/null
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt
@@ -0,0 +1,51 @@
+/*
+ * 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 co.touchlab.kermit.Logger
+import com.juul.kable.logs.LogEngine
+
+/**
+ * Bridges Kable's internal logging to [Kermit][Logger] so BLE lifecycle events (connect, disconnect, subscribe, GATT
+ * operations) appear in the standard app logs rather than going to [System.out] via Kable's default
+ * [com.juul.kable.logs.SystemLogEngine].
+ */
+internal object KermitLogEngine : LogEngine {
+ override fun verbose(throwable: Throwable?, tag: String, message: String) {
+ Logger.v(throwable) { "[$tag] $message" }
+ }
+
+ override fun debug(throwable: Throwable?, tag: String, message: String) {
+ Logger.d(throwable) { "[$tag] $message" }
+ }
+
+ override fun info(throwable: Throwable?, tag: String, message: String) {
+ Logger.i(throwable) { "[$tag] $message" }
+ }
+
+ override fun warn(throwable: Throwable?, tag: String, message: String) {
+ Logger.w(throwable) { "[$tag] $message" }
+ }
+
+ override fun error(throwable: Throwable?, tag: String, message: String) {
+ Logger.e(throwable) { "[$tag] $message" }
+ }
+
+ override fun assert(throwable: Throwable?, tag: String, message: String) {
+ Logger.e(throwable) { "[$tag] $message" }
+ }
+}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
index 389516521..f69214187 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleConstants.kt
@@ -38,8 +38,6 @@ object MeshtasticBleConstants {
/** Characteristic for receiving log notifications from the radio. */
val LOGRADIO_CHARACTERISTIC: Uuid = Uuid.parse("5a3d6e49-06e6-4423-9944-e9de8cdf9547")
- val FROMRADIOSYNC_CHARACTERISTIC: Uuid = Uuid.parse("888a50c3-982d-45db-9963-c7923769165d")
-
// --- OTA Characteristics ---
/** The Meshtastic OTA service UUID (ESP32 Unified OTA). */
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt
similarity index 51%
rename from core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
rename to core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt
index 455779937..3342cf24f 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt
@@ -17,32 +17,44 @@
package org.meshtastic.core.ble
import com.juul.kable.Advertisement
+import com.juul.kable.ExperimentalApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
-class KableBleDevice(val advertisement: Advertisement) : BleDevice {
- override val name: String?
- get() = advertisement.name
+/**
+ * Unified [BleDevice] implementation for all BLE devices — scanned, bonded, or both.
+ *
+ * When created from a live BLE scan, [advertisement] is populated and used for optimal peripheral construction via
+ * `Peripheral(advertisement)`. When created from the OS bonded device list (address only), [advertisement] is `null`
+ * and the peripheral is constructed via `createPeripheral(address)` with `autoConnect = true`.
+ *
+ * @param address The device's MAC address (or platform identifier string).
+ * @param name The device's display name, if known.
+ * @param advertisement The Kable [Advertisement] from a live scan, or `null` for bonded-only devices.
+ */
+class MeshtasticBleDevice(
+ override val address: String,
+ override val name: String? = null,
+ val advertisement: Advertisement? = null,
+) : BleDevice {
- override val address: String
- get() = advertisement.identifier.toString()
-
- private val _state = MutableStateFlow(BleConnectionState.Disconnected)
- override val state: StateFlow = _state
+ private val _state = MutableStateFlow(BleConnectionState.Disconnected())
+ override val state: StateFlow = _state.asStateFlow()
// Bonding is handled by the OS pairing dialog on Android; on desktop Kable connects directly.
override val isBonded: Boolean = true
override val isConnected: Boolean
- get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
+ get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.active?.address == address
- @OptIn(com.juul.kable.ExperimentalApi::class)
+ @OptIn(ExperimentalApi::class)
override suspend fun readRssi(): Int {
- val peripheral = ActiveBleConnection.activePeripheral
- return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
- peripheral.rssi()
+ val active = ActiveBleConnection.active
+ return if (active != null && active.address == address) {
+ active.peripheral.rssi()
} else {
- advertisement.rssi
+ advertisement?.rssi ?: 0
}
}
@@ -50,6 +62,7 @@ class KableBleDevice(val advertisement: Advertisement) : BleDevice {
// No-op: bonding is OS-managed on Android and not required on desktop.
}
+ /** Updates the tracked connection state. Called by [KableBleConnection] when the peripheral state changes. */
internal fun updateState(newState: BleConnectionState) {
_state.value = newState
}
diff --git a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt
index d1a557a42..7a69e9524 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt
@@ -28,4 +28,22 @@ interface MeshtasticRadioProfile {
/** Sends a packet to the radio. */
suspend fun sendToRadio(packet: ByteArray)
+
+ /**
+ * Requests a drain of the FROMRADIO characteristic without writing to TORADIO.
+ *
+ * This is useful when the firmware has queued a response (e.g. `queueStatus` after a heartbeat) but did not send a
+ * FROMNUM notification. Without an explicit drain trigger the response would sit unread until the next unrelated
+ * FROMNUM notification arrives.
+ */
+ fun requestDrain() {}
+
+ /**
+ * Suspends until GATT notifications are enabled (CCCD written) for the primary observation characteristic.
+ *
+ * Callers should await this before triggering the Meshtastic handshake (`want_config_id`) to guarantee that FROMNUM
+ * notifications will be delivered. The default implementation returns immediately for profiles where CCCD readiness
+ * is not observable (e.g. fakes and non-BLE transports).
+ */
+ suspend fun awaitSubscriptionReady() {}
}
diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt
new file mode 100644
index 000000000..1170b973b
--- /dev/null
+++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ble
+
+import com.juul.kable.GattStatusException
+import com.juul.kable.NotConnectedException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Tests for [classifyBleException] — the boundary between Kable types and the transport layer.
+ *
+ * [GattRequestRejectedException] and [UnmetRequirementException] have `internal` constructors in Kable, so they cannot
+ * be instantiated from outside the library. The `else -> null` branch covers the fallback for any unrecognised
+ * throwable.
+ */
+class BleExceptionClassifierTest {
+
+ @Test
+ fun `GattStatusException maps to non-permanent with status code`() {
+ val ex = GattStatusException(message = "GATT failure", status = 133)
+ val info = ex.classifyBleException()
+ assertNotNull(info)
+ assertFalse(info.isPermanent)
+ assertEquals(133, info.gattStatus)
+ assertTrue(info.message.contains("133"))
+ }
+
+ @Test
+ fun `NotConnectedException maps to non-permanent without status code`() {
+ val ex = NotConnectedException("disconnected")
+ val info = ex.classifyBleException()
+ assertNotNull(info)
+ assertFalse(info.isPermanent)
+ assertNull(info.gattStatus)
+ assertEquals("Not connected", info.message)
+ }
+
+ @Test
+ fun `unrelated exception returns null`() {
+ val ex = IllegalStateException("something else")
+ assertNull(ex.classifyBleException())
+ }
+
+ @Test
+ fun `RuntimeException returns null`() {
+ assertNull(RuntimeException("boom").classifyBleException())
+ }
+}
diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt
new file mode 100644
index 000000000..d947dd04d
--- /dev/null
+++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt
@@ -0,0 +1,51 @@
+/*
+ * 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 kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+
+/** Tests for [DisconnectReason] and [BleConnectionState.Disconnected]. */
+class DisconnectReasonTest {
+
+ @Test
+ @Suppress("MagicNumber")
+ fun `PlatformSpecific toString includes status code`() {
+ val reason = DisconnectReason.PlatformSpecific(133)
+ val str = reason.toString()
+ assertContains(str, "133", message = "PlatformSpecific.toString() should include the status code")
+ }
+
+ @Test
+ fun `Disconnected default reason is Unknown`() {
+ val state = BleConnectionState.Disconnected()
+ assertEquals(DisconnectReason.Unknown, state.reason)
+ }
+
+ @Test
+ fun `Disconnected preserves explicit reason`() {
+ val state = BleConnectionState.Disconnected(DisconnectReason.Timeout)
+ assertEquals(DisconnectReason.Timeout, state.reason)
+ }
+
+ @Test
+ fun `data object reasons are singletons`() {
+ assertEquals(DisconnectReason.Unknown, DisconnectReason.Unknown)
+ assertEquals(DisconnectReason.LocalDisconnect, DisconnectReason.LocalDisconnect)
+ }
+}
diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt
new file mode 100644
index 000000000..64286fd70
--- /dev/null
+++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.testing.FakeBleService
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+/**
+ * Tests for [KableMeshtasticRadioProfile] — the GATT characteristic orchestration layer.
+ *
+ * Uses [FakeBleService] from `core:testing`. Since [FakeBleService] inherits the default [BleService.observe] overload
+ * (which invokes `onSubscription` via `onStart`), `awaitSubscriptionReady()` completes immediately — matching the
+ * behaviour expected from non-Kable implementations.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+class KableMeshtasticRadioProfileTest {
+
+ private fun createService(): FakeBleService = FakeBleService().apply {
+ addCharacteristic(MeshtasticBleConstants.FROMNUM_CHARACTERISTIC)
+ addCharacteristic(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC)
+ addCharacteristic(MeshtasticBleConstants.TORADIO_CHARACTERISTIC)
+ }
+
+ @Test
+ fun `awaitSubscriptionReady completes when using FakeBleService`() = runTest {
+ val service = createService()
+ val profile = KableMeshtasticRadioProfile(service)
+
+ // Start collecting fromRadio to activate the observe() flow (which triggers onSubscription)
+ val collectJob = launch { profile.fromRadio.first() }
+ advanceUntilIdle()
+
+ // Should not hang — FakeBleService's default observe(char, onSubscription) fires onSubscription eagerly
+ profile.awaitSubscriptionReady()
+
+ collectJob.cancel()
+ }
+
+ @Test
+ fun `sendToRadio writes to TORADIO and triggers drain`() = runTest {
+ val service = createService()
+ val profile = KableMeshtasticRadioProfile(service)
+ val testData = byteArrayOf(1, 2, 3)
+
+ // Enqueue empty read so the drain loop terminates
+ service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
+
+ profile.sendToRadio(testData)
+
+ assertEquals(1, service.writes.size)
+ assertTrue(service.writes[0].data.contentEquals(testData))
+ }
+
+ @Test
+ fun `fromRadio emits packets from FROMRADIO reads`() = runTest {
+ val service = createService()
+ val profile = KableMeshtasticRadioProfile(service)
+
+ val packet1 = byteArrayOf(10, 20, 30)
+ service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, packet1)
+ // Empty read terminates the drain loop
+ service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
+
+ val received = async { profile.fromRadio.first() }
+ advanceUntilIdle()
+
+ assertTrue(received.await().contentEquals(packet1))
+ }
+
+ @Test
+ fun `requestDrain triggers additional FROMRADIO reads`() = runTest {
+ val service = createService()
+ val profile = KableMeshtasticRadioProfile(service)
+
+ val received = mutableListOf()
+
+ // Start the fromRadio collector
+ val collectJob = launch { profile.fromRadio.collect { received.add(it) } }
+ advanceUntilIdle()
+
+ // First drain should have completed (initial seed) with nothing queued.
+ // Now enqueue a packet and trigger a manual drain.
+ val latePacket = byteArrayOf(99)
+ service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, latePacket)
+ service.enqueueRead(MeshtasticBleConstants.FROMRADIO_CHARACTERISTIC, ByteArray(0))
+ profile.requestDrain()
+ advanceUntilIdle()
+
+ assertEquals(1, received.size)
+ assertTrue(received[0].contentEquals(latePacket))
+
+ collectJob.cancel()
+ }
+
+ @Test
+ fun `MeshtasticRadioProfile default awaitSubscriptionReady returns immediately`() = runTest {
+ val profile =
+ object : MeshtasticRadioProfile {
+ override val fromRadio = emptyFlow()
+ override val logRadio = emptyFlow()
+
+ override suspend fun sendToRadio(packet: ByteArray) {}
+ }
+ // Should not hang — default implementation is a no-op
+ profile.awaitSubscriptionReady()
+ }
+}
diff --git a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt
new file mode 100644
index 000000000..18c7be4da
--- /dev/null
+++ b/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.ble
+
+import com.juul.kable.State
+import kotlinx.coroutines.test.TestScope
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNull
+
+/** Tests for [toBleConnectionState] and [toDisconnectReason] mappings. */
+class KableStateMappingTest {
+
+ // --- toBleConnectionState ---
+
+ @Test
+ fun `Connecting maps to BleConnectionState Connecting`() {
+ val result = State.Connecting.Bluetooth.toBleConnectionState(hasStartedConnecting = false)
+ assertIs(result)
+ }
+
+ @Test
+ fun `Connected maps to BleConnectionState Connected`() {
+ val scope = TestScope()
+ val result = State.Connected(scope).toBleConnectionState(hasStartedConnecting = true)
+ assertIs(result)
+ }
+
+ @Test
+ fun `Disconnecting maps to BleConnectionState Disconnecting`() {
+ val result = State.Disconnecting.toBleConnectionState(hasStartedConnecting = true)
+ assertIs(result)
+ }
+
+ @Test
+ fun `Disconnected before connecting started returns null`() {
+ val result = State.Disconnected(status = null).toBleConnectionState(hasStartedConnecting = false)
+ assertNull(result)
+ }
+
+ @Test
+ fun `Disconnected after connecting started maps with reason`() {
+ val result =
+ State.Disconnected(State.Disconnected.Status.Timeout).toBleConnectionState(hasStartedConnecting = true)
+ assertIs(result)
+ assertEquals(DisconnectReason.Timeout, result.reason)
+ }
+
+ // --- toDisconnectReason ---
+
+ @Test
+ fun `null status maps to Unknown`() {
+ assertEquals(DisconnectReason.Unknown, null.toDisconnectReason())
+ }
+
+ @Test
+ fun `CentralDisconnected maps to LocalDisconnect`() {
+ assertEquals(
+ DisconnectReason.LocalDisconnect,
+ State.Disconnected.Status.CentralDisconnected.toDisconnectReason(),
+ )
+ }
+
+ @Test
+ fun `PeripheralDisconnected maps to RemoteDisconnect`() {
+ assertEquals(
+ DisconnectReason.RemoteDisconnect,
+ State.Disconnected.Status.PeripheralDisconnected.toDisconnectReason(),
+ )
+ }
+
+ @Test
+ fun `Failed maps to ConnectionFailed`() {
+ assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.Failed.toDisconnectReason())
+ }
+
+ @Test
+ fun `Timeout maps to Timeout`() {
+ assertEquals(DisconnectReason.Timeout, State.Disconnected.Status.Timeout.toDisconnectReason())
+ }
+
+ @Test
+ fun `LinkManagerProtocolTimeout maps to Timeout`() {
+ assertEquals(
+ DisconnectReason.Timeout,
+ State.Disconnected.Status.LinkManagerProtocolTimeout.toDisconnectReason(),
+ )
+ }
+
+ @Test
+ fun `Cancelled maps to Cancelled`() {
+ assertEquals(DisconnectReason.Cancelled, State.Disconnected.Status.Cancelled.toDisconnectReason())
+ }
+
+ @Test
+ fun `EncryptionTimedOut maps to EncryptionFailed`() {
+ assertEquals(
+ DisconnectReason.EncryptionFailed,
+ State.Disconnected.Status.EncryptionTimedOut.toDisconnectReason(),
+ )
+ }
+
+ @Test
+ fun `L2CapFailure maps to ConnectionFailed`() {
+ assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.L2CapFailure.toDisconnectReason())
+ }
+
+ @Test
+ fun `ConnectionLimitReached maps to ConnectionFailed`() {
+ assertEquals(
+ DisconnectReason.ConnectionFailed,
+ State.Disconnected.Status.ConnectionLimitReached.toDisconnectReason(),
+ )
+ }
+
+ @Test
+ fun `UnknownDevice maps to ConnectionFailed`() {
+ assertEquals(DisconnectReason.ConnectionFailed, State.Disconnected.Status.UnknownDevice.toDisconnectReason())
+ }
+
+ @Test
+ @Suppress("MagicNumber")
+ fun `Unknown status maps to PlatformSpecific with code`() {
+ val result = State.Disconnected.Status.Unknown(status = 42).toDisconnectReason()
+ assertIs(result)
+ assertEquals(42, result.code)
+ }
+}
diff --git a/core/common/README.md b/core/common/README.md
index da7700ac5..979586213 100644
--- a/core/common/README.md
+++ b/core/common/README.md
@@ -11,8 +11,8 @@ Contains general-purpose extensions and helpers:
- **Time**: Utilities for handling timestamps and durations.
- **Exceptions**: Standardized exception types for common error scenarios.
-### 2. `ByteUtils.kt`
-Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
+### 2. `MetricFormatter.kt`
+Centralized utility for display strings — temperature, voltage, current, percent, humidity, pressure, SNR, RSSI. Ensures consistent unit spacing and formatting across all UI surfaces.
### 3. `BuildConfigProvider.kt`
An interface for accessing build-time configuration in a multiplatform-friendly way.
diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts
index 08ec08865..e4d94943e 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -37,6 +37,7 @@ kotlin {
implementation(libs.kotlinx.coroutines.core)
api(libs.kotlinx.datetime)
api(libs.okio)
+ api(libs.uri.kmp)
implementation(libs.kermit)
}
androidMain.dependencies { api(libs.androidx.core.ktx) }
diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
deleted file mode 100644
index a99bccd84..000000000
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
+++ /dev/null
@@ -1,45 +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.core.common.util
-
-import android.net.Uri
-
-actual class CommonUri(private val uri: Uri) {
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.pathSegments
-
- actual fun getQueryParameter(key: String): String? = uri.getQueryParameter(key)
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean =
- uri.getBooleanQueryParameter(key, defaultValue)
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(Uri.parse(uriString))
- }
-
- fun toUri(): Uri = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
deleted file mode 100644
index c27040e73..000000000
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-/*
- * Copyright (c) 2025 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
-
-/** Utility function to make it easy to declare byte arrays */
-fun byteArrayOfInts(vararg ints: Int) = ByteArray(ints.size) { pos -> ints[pos].toByte() }
-
-fun xorHash(b: ByteArray) = b.fold(0) { acc, x -> acc xor (x.toInt() and BYTE_MASK) }
-
-private const val BYTE_MASK = 0xff
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 000000000..2a27b9690
--- /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/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
similarity index 62%
rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
index 0babff5b1..1072801c6 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2026 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
@@ -17,13 +17,14 @@
package org.meshtastic.core.common.util
/**
- * A multiplatform representation of a URI, primarily used to safely pass Android Uri references through commonMain
- * modules without coupling them to the android.net.Uri class.
+ * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
+ * blank, or sentinel values (`"N"`, `"NULL"`).
*/
-data class MeshtasticUri(val uriString: String) {
- override fun toString(): String = uriString
-
- companion object {
- fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
+fun normalizeAddress(addr: String?): String {
+ val u = addr?.trim()?.uppercase()
+ return when {
+ u.isNullOrBlank() -> "DEFAULT"
+ u == "N" || u == "NULL" -> "DEFAULT"
+ else -> u.replace(":", "")
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
index 7079cbf5e..00b15861f 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
@@ -16,22 +16,14 @@
*/
package org.meshtastic.core.common.util
-/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
-expect class CommonUri {
- val host: String?
- val fragment: String?
- val pathSegments: List
+import com.eygraber.uri.Uri
- fun getQueryParameter(key: String): String?
-
- fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean
-
- override fun toString(): String
-
- companion object {
- fun parse(uriString: String): CommonUri
- }
-}
-
-/** Extension to convert platform Uri to CommonUri in Android source sets. */
-expect fun CommonUri.toPlatformUri(): Any
+/**
+ * Platform-agnostic URI representation backed by [uri-kmp](https://github.com/eygraber/uri-kmp).
+ *
+ * This typealias replaces the former `expect/actual` class, providing a concrete pure-Kotlin implementation that works
+ * identically on Android, JVM, and iOS without platform stubs.
+ *
+ * On Android, use `com.eygraber.uri.toAndroidUri()` to convert to `android.net.Uri`.
+ */
+typealias CommonUri = Uri
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
index ccd565286..92137375c 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Exceptions.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.common.util
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
object Exceptions {
/** Set by the application to provide a custom crash reporting implementation. */
@@ -47,10 +48,12 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
-/** Suspend-compatible variant of [ignoreException]. */
+/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
suspend fun ignoreExceptionSuspend(silent: Boolean = false, inner: suspend () -> Unit) {
try {
inner()
+ } catch (e: CancellationException) {
+ throw e
} catch (@Suppress("TooGenericExceptionCaught") ex: Exception) {
if (!silent) {
Logger.w(ex) { "Ignoring exception" }
@@ -69,3 +72,41 @@ fun exceptionReporter(inner: () -> Unit) {
Exceptions.report(ex, "exceptionReporter", "Uncaught Exception")
}
}
+
+/**
+ * Like [kotlin.runCatching], but re-throws [CancellationException] to preserve structured concurrency. Use this instead
+ * of [runCatching] in coroutine contexts.
+ */
+@Suppress("TooGenericExceptionCaught")
+inline fun safeCatching(block: () -> T): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Result.failure(e)
+}
+
+/** Like [kotlin.runCatching] receiver variant, but re-throws [CancellationException]. */
+@Suppress("TooGenericExceptionCaught")
+inline fun T.safeCatching(block: T.() -> R): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (e: Exception) {
+ Result.failure(e)
+}
+
+/**
+ * Like [safeCatching] but also catches JVM [Error]s (e.g. [ExceptionInInitializerError] raised by compose-resources'
+ * lazy skiko initialization on the desktop JVM test classpath). Still re-throws [CancellationException] so structured
+ * concurrency is preserved. Use when the block invokes code whose failure modes include static-initializer errors and
+ * the caller only needs a best-effort fallback.
+ */
+@Suppress("TooGenericExceptionCaught")
+inline fun safeCatchingAll(block: () -> T): Result = try {
+ Result.success(block())
+} catch (e: CancellationException) {
+ throw e
+} catch (t: Throwable) {
+ Result.failure(t)
+}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
index d54455df8..7a24819a7 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -16,5 +16,114 @@
*/
package org.meshtastic.core.common.util
-/** Multiplatform string formatting helper. */
-expect fun formatString(pattern: String, vararg args: Any?): String
+/**
+ * Pure-Kotlin multiplatform string formatting.
+ *
+ * Implements the subset of Java's `String.format()` patterns used in this codebase:
+ * - `%s`, `%d` — positional or sequential string/integer
+ * - `%N$s`, `%N$d` — explicit positional string/integer
+ * - `%N$.Nf`, `%.Nf` — float with decimal precision
+ * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
+ * - `%%` — literal percent
+ */
+@Suppress("CyclomaticComplexMethod", "LongMethod", "LoopWithTooManyJumpStatements")
+fun formatString(pattern: String, vararg args: Any?): String = buildString {
+ var i = 0
+ var autoIndex = 0
+ while (i < pattern.length) {
+ if (pattern[i] != '%') {
+ append(pattern[i])
+ i++
+ continue
+ }
+ i++ // skip '%'
+ if (i >= pattern.length) break
+
+ // Literal %%
+ if (pattern[i] == '%') {
+ append('%')
+ i++
+ continue
+ }
+
+ // Parse optional positional index (N$)
+ var explicitIndex: Int? = null
+ val startPos = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i < pattern.length && pattern[i] == '$' && i > startPos) {
+ explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
+ i++ // skip '$'
+ } else {
+ i = startPos // rewind — digits are part of width/precision, not positional index
+ }
+
+ // Parse optional flags (zero-pad)
+ var zeroPad = false
+ if (i < pattern.length && pattern[i] == '0') {
+ zeroPad = true
+ i++
+ }
+
+ // Parse optional width
+ var width: Int? = null
+ val widthStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > widthStart) {
+ width = pattern.substring(widthStart, i).toInt()
+ }
+
+ // Parse optional precision (.N)
+ var precision: Int? = null
+ if (i < pattern.length && pattern[i] == '.') {
+ i++ // skip '.'
+ val precStart = i
+ while (i < pattern.length && pattern[i].isDigit()) i++
+ if (i > precStart) {
+ precision = pattern.substring(precStart, i).toInt()
+ }
+ }
+
+ // Parse conversion character
+ if (i >= pattern.length) break
+ val conversion = pattern[i]
+ i++
+
+ val argIndex = explicitIndex ?: autoIndex++
+ val arg = args.getOrNull(argIndex)
+
+ when (conversion) {
+ 's' -> append(arg?.toString() ?: "null")
+ 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
+ 'f' -> {
+ val value = (arg as? Number)?.toDouble() ?: 0.0
+ val places = precision ?: DEFAULT_FLOAT_PRECISION
+ append(NumberFormatter.format(value, places))
+ }
+ 'x',
+ 'X',
+ -> {
+ val value = (arg as? Number)?.toLong() ?: 0L
+ // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
+ val masked = if (arg is Int) value and INT_MASK else value
+ var hex = masked.toString(HEX_RADIX)
+ if (conversion == 'X') hex = hex.uppercase()
+ val padChar = if (zeroPad) '0' else ' '
+ val padWidth = width ?: 0
+ append(hex.padStart(padWidth, padChar))
+ }
+ else -> {
+ // Unknown conversion — reproduce original token
+ append('%')
+ if (explicitIndex != null) append("${explicitIndex + 1}$")
+ if (zeroPad) append('0')
+ if (width != null) append(width)
+ if (precision != null) append(".$precision")
+ append(conversion)
+ }
+ }
+ }
+}
+
+private const val DEFAULT_FLOAT_PRECISION = 6
+private const val HEX_RADIX = 16
+private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index e3612dfda..1abb8807c 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -79,9 +79,7 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
- fun optimizeUtf8StringWithHomoglyphs(value: String): String {
- val stringBuilder = StringBuilder()
- for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
- return stringBuilder.toString()
+ fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
+ for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
new file mode 100644
index 000000000..51905ff41
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.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 .
+ */
+package org.meshtastic.core.common.util
+
+/**
+ * Centralized metric formatting for display strings. Eliminates duplicated `formatString` patterns across Node,
+ * NodeItem, and metric screens.
+ *
+ * All methods return locale-independent strings using [NumberFormatter] (dot decimal separator), which is intentional
+ * for a mesh networking app where consistency matters.
+ */
+@Suppress("TooManyFunctions")
+object MetricFormatter {
+
+ fun temperature(celsius: Float, isFahrenheit: Boolean): String {
+ val value = if (isFahrenheit) celsius * FAHRENHEIT_SCALE + FAHRENHEIT_OFFSET else celsius
+ val unit = if (isFahrenheit) "°F" else "°C"
+ return "${NumberFormatter.format(value, 1)}$unit"
+ }
+
+ fun voltage(volts: Float, decimalPlaces: Int = 2): String = "${NumberFormatter.format(volts, decimalPlaces)} V"
+
+ fun current(milliAmps: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(milliAmps, decimalPlaces)} mA"
+
+ fun percent(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)}%"
+
+ fun percent(value: Int): String = "$value%"
+
+ fun humidity(value: Float): String = percent(value, 0)
+
+ fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
+
+ fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
+
+ fun rssi(value: Int): String = "$value dBm"
+
+ fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(metersPerSecond, decimalPlaces)} m/s"
+
+ fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String =
+ "${NumberFormatter.format(millimeters, decimalPlaces)} mm"
+}
+
+private const val FAHRENHEIT_SCALE = 1.8f
+private const val FAHRENHEIT_OFFSET = 32
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
new file mode 100644
index 000000000..040861b8d
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class AddressUtilsTest {
+
+ @Test
+ fun nullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(null))
+ }
+
+ @Test
+ fun blankReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress(""))
+ assertEquals("DEFAULT", normalizeAddress(" "))
+ }
+
+ @Test
+ fun sentinelNReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("N"))
+ assertEquals("DEFAULT", normalizeAddress("n"))
+ }
+
+ @Test
+ fun sentinelNullReturnsDefault() {
+ assertEquals("DEFAULT", normalizeAddress("NULL"))
+ assertEquals("DEFAULT", normalizeAddress("null"))
+ assertEquals("DEFAULT", normalizeAddress("Null"))
+ }
+
+ @Test
+ fun stripsColons() {
+ assertEquals("AABBCCDD", normalizeAddress("AA:BB:CC:DD"))
+ }
+
+ @Test
+ fun uppercases() {
+ assertEquals("AABBCCDD", normalizeAddress("aa:bb:cc:dd"))
+ }
+
+ @Test
+ fun trimsWhitespace() {
+ assertEquals("AABBCC", normalizeAddress(" AA:BB:CC "))
+ }
+
+ @Test
+ fun alreadyNormalizedPassesThrough() {
+ assertEquals("AABBCCDD", normalizeAddress("AABBCCDD"))
+ }
+
+ @Test
+ fun mixedCaseWithColons() {
+ assertEquals("AABBCC", normalizeAddress("aA:Bb:cC"))
+ }
+}
diff --git a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
similarity index 51%
rename from core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
index 0e754708c..899938ba4 100644
--- a/core/common/src/jvmTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/CommonUriTest.kt
@@ -18,27 +18,26 @@ package org.meshtastic.core.common.util
import kotlin.test.Test
import kotlin.test.assertEquals
-import kotlin.test.assertTrue
class CommonUriTest {
-
@Test
- fun testParse() {
- val uri = CommonUri.parse("https://meshtastic.org/path/to/page?param1=value1¶m2=true#fragment")
- assertEquals("meshtastic.org", uri.host)
- assertEquals("fragment", uri.fragment)
- assertEquals(listOf("path", "to", "page"), uri.pathSegments)
- assertEquals("value1", uri.getQueryParameter("param1"))
- assertTrue(uri.getBooleanQueryParameter("param2", false))
+ fun testParseAndToString() {
+ val uriString = "content://com.example.provider/file.txt"
+ val uri = CommonUri.parse(uriString)
+ assertEquals(uriString, uri.toString())
}
@Test
- fun testBooleanParameters() {
- val uri = CommonUri.parse("meshtastic://test?t1=true&t2=1&t3=yes&f1=false&f2=0")
- assertTrue(uri.getBooleanQueryParameter("t1", false))
- assertTrue(uri.getBooleanQueryParameter("t2", false))
- assertTrue(uri.getBooleanQueryParameter("t3", false))
- assertTrue(!uri.getBooleanQueryParameter("f1", true))
- assertTrue(!uri.getBooleanQueryParameter("f2", true))
+ fun testQueryParameters() {
+ val uri = CommonUri.parse("https://meshtastic.org/d/#key=value&complete=true")
+ assertEquals("meshtastic.org", uri.host)
+ assertEquals("key=value&complete=true", uri.fragment)
+ }
+
+ @Test
+ fun testFileUri() {
+ val uri = CommonUri.parse("file:///tmp/export.csv")
+ assertEquals("file", uri.scheme)
+ assertEquals("/tmp/export.csv", uri.path)
}
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
index 94b81f0fb..de2d20e9e 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
@@ -93,4 +93,48 @@ class FormatStringTest {
fun sequentialFloatSubstitution() {
assertEquals("1.2 3.5", formatString("%.1f %.1f", 1.23, 3.45))
}
+
+ // Hex format tests
+
+ @Test
+ fun lowercaseHex() {
+ assertEquals("ff", formatString("%x", 255))
+ }
+
+ @Test
+ fun uppercaseHex() {
+ assertEquals("FF", formatString("%X", 255))
+ }
+
+ @Test
+ fun zeroPaddedHex() {
+ assertEquals("000000ff", formatString("%08x", 255))
+ }
+
+ @Test
+ fun zeroPaddedHexNodeId() {
+ assertEquals("!deadbeef", formatString("!%08x", 0xDEADBEEF.toInt()))
+ }
+
+ @Test
+ fun hexZeroValue() {
+ assertEquals("00000000", formatString("%08x", 0))
+ }
+
+ @Test
+ fun positionalHex() {
+ assertEquals("Node ff id 42", formatString("Node %1\$x id %2\$d", 255, 42))
+ }
+
+ // Edge case tests
+
+ @Test
+ fun trailingPercent() {
+ assertEquals("hello", formatString("hello%"))
+ }
+
+ @Test
+ fun outOfBoundsArgIndex() {
+ assertEquals("null", formatString("%3\$s", "only_one"))
+ }
}
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
new file mode 100644
index 000000000..94781fca3
--- /dev/null
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MetricFormatterTest {
+
+ @Test
+ fun temperatureCelsius() {
+ assertEquals("25.3°C", MetricFormatter.temperature(25.3f, isFahrenheit = false))
+ }
+
+ @Test
+ fun temperatureFahrenheit() {
+ assertEquals("77.0°F", MetricFormatter.temperature(25.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureNegative() {
+ assertEquals("-10.5°C", MetricFormatter.temperature(-10.5f, isFahrenheit = false))
+ }
+
+ @Test
+ fun voltage() {
+ assertEquals("3.72 V", MetricFormatter.voltage(3.72f))
+ }
+
+ @Test
+ fun voltageOneDecimal() {
+ assertEquals("3.7 V", MetricFormatter.voltage(3.725f, decimalPlaces = 1))
+ }
+
+ @Test
+ fun current() {
+ assertEquals("150.3 mA", MetricFormatter.current(150.3f))
+ }
+
+ @Test
+ fun percentFloat() {
+ assertEquals("85.5%", MetricFormatter.percent(85.5f))
+ }
+
+ @Test
+ fun percentInt() {
+ assertEquals("85%", MetricFormatter.percent(85))
+ }
+
+ @Test
+ fun humidity() {
+ assertEquals("65%", MetricFormatter.humidity(65.4f))
+ }
+
+ @Test
+ fun pressure() {
+ assertEquals("1013.3 hPa", MetricFormatter.pressure(1013.25f))
+ }
+
+ @Test
+ fun snr() {
+ assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
+ }
+
+ @Test
+ fun rssi() {
+ assertEquals("-90 dBm", MetricFormatter.rssi(-90))
+ }
+
+ @Test
+ fun temperatureFreezingFahrenheit() {
+ assertEquals("32.0°F", MetricFormatter.temperature(0.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun temperatureBoilingFahrenheit() {
+ assertEquals("212.0°F", MetricFormatter.temperature(100.0f, isFahrenheit = true))
+ }
+
+ @Test
+ fun voltageZero() {
+ assertEquals("0.00 V", MetricFormatter.voltage(0.0f))
+ }
+
+ @Test
+ fun currentZero() {
+ assertEquals("0.0 mA", MetricFormatter.current(0.0f))
+ }
+
+ @Test
+ fun percentZero() {
+ assertEquals("0%", MetricFormatter.percent(0))
+ }
+
+ @Test
+ fun percentHundred() {
+ assertEquals("100%", MetricFormatter.percent(100))
+ }
+
+ @Test
+ fun rssiZero() {
+ assertEquals("0 dBm", MetricFormatter.rssi(0))
+ }
+
+ @Test
+ fun snrNegative() {
+ assertEquals("-5.5 dB", MetricFormatter.snr(-5.5f))
+ }
+
+ @Test
+ fun windSpeed() {
+ assertEquals("12.3 m/s", MetricFormatter.windSpeed(12.34f))
+ }
+
+ @Test
+ fun windSpeedZero() {
+ assertEquals("0.0 m/s", MetricFormatter.windSpeed(0.0f))
+ }
+
+ @Test
+ fun rainfall() {
+ assertEquals("2.5 mm", MetricFormatter.rainfall(2.54f))
+ }
+
+ @Test
+ fun rainfallZero() {
+ assertEquals("0.0 mm", MetricFormatter.rainfall(0.0f))
+ }
+}
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
deleted file mode 100644
index c2e95a5b0..000000000
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ /dev/null
@@ -1,130 +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.common.util
-
-/**
- * Apple (iOS) implementation of string formatting.
- *
- * Implements a subset of Java's `String.format()` patterns used in this codebase:
- * - `%s`, `%d` — positional or sequential string/integer
- * - `%N$s`, `%N$d` — explicit positional string/integer
- * - `%N$.Nf`, `%.Nf` — float with decimal precision
- * - `%x`, `%X`, `%08x` — hexadecimal (lower/upper, optional zero-padded width)
- * - `%%` — literal percent
- *
- * This avoids a dependency on `NSString.stringWithFormat` (which uses Obj-C `%@` conventions).
- */
-actual fun formatString(pattern: String, vararg args: Any?): String = buildString {
- var i = 0
- var autoIndex = 0
- while (i < pattern.length) {
- if (pattern[i] != '%') {
- append(pattern[i])
- i++
- continue
- }
- i++ // skip '%'
- if (i >= pattern.length) break
-
- // Literal %%
- if (pattern[i] == '%') {
- append('%')
- i++
- continue
- }
-
- // Parse optional positional index (N$)
- var explicitIndex: Int? = null
- val startPos = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i < pattern.length && pattern[i] == '$' && i > startPos) {
- explicitIndex = pattern.substring(startPos, i).toInt() - 1 // 1-indexed → 0-indexed
- i++ // skip '$'
- } else {
- i = startPos // rewind — digits are part of width/precision, not positional index
- }
-
- // Parse optional flags (zero-pad)
- var zeroPad = false
- if (i < pattern.length && pattern[i] == '0') {
- zeroPad = true
- i++
- }
-
- // Parse optional width
- var width: Int? = null
- val widthStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > widthStart) {
- width = pattern.substring(widthStart, i).toInt()
- }
-
- // Parse optional precision (.N)
- var precision: Int? = null
- if (i < pattern.length && pattern[i] == '.') {
- i++ // skip '.'
- val precStart = i
- while (i < pattern.length && pattern[i].isDigit()) i++
- if (i > precStart) {
- precision = pattern.substring(precStart, i).toInt()
- }
- }
-
- // Parse conversion character
- if (i >= pattern.length) break
- val conversion = pattern[i]
- i++
-
- val argIndex = explicitIndex ?: autoIndex++
- val arg = args.getOrNull(argIndex)
-
- when (conversion) {
- 's' -> append(arg?.toString() ?: "null")
- 'd' -> append((arg as? Number)?.toLong()?.toString() ?: arg?.toString() ?: "0")
- 'f' -> {
- val value = (arg as? Number)?.toDouble() ?: 0.0
- val places = precision ?: DEFAULT_FLOAT_PRECISION
- append(NumberFormatter.format(value, places))
- }
- 'x',
- 'X',
- -> {
- val value = (arg as? Number)?.toLong() ?: 0L
- // Mask to 32 bits when the original arg fits in an Int to match unsigned behaviour.
- val masked = if (arg is Int) value and INT_MASK else value
- var hex = masked.toString(HEX_RADIX)
- if (conversion == 'X') hex = hex.uppercase()
- val padChar = if (zeroPad) '0' else ' '
- val padWidth = width ?: 0
- append(hex.padStart(padWidth, padChar))
- }
- else -> {
- // Unknown conversion — reproduce original token
- append('%')
- if (explicitIndex != null) append("${explicitIndex + 1}$")
- if (zeroPad) append('0')
- if (width != null) append(width)
- if (precision != null) append(".$precision")
- append(conversion)
- }
- }
- }
-}
-
-private const val DEFAULT_FLOAT_PRECISION = 6
-private const val HEX_RADIX = 16
-private const val INT_MASK = 0xFFFFFFFFL
diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
index 35e2906ff..7556105b3 100644
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
@@ -22,20 +22,6 @@ actual object BuildUtils {
actual val sdkInt: Int = 0
}
-actual class CommonUri(actual val host: String?, actual val fragment: String?, actual val pathSegments: List) {
- actual fun getQueryParameter(key: String): String? = null
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean = defaultValue
-
- actual override fun toString(): String = ""
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(null, null, emptyList())
- }
-}
-
-actual fun CommonUri.toPlatformUri(): Any = Any()
-
actual object DateFormatter {
actual fun formatRelativeTime(timestampMillis: Long): String = ""
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
deleted file mode 100644
index c10c015bc..000000000
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
+++ /dev/null
@@ -1,49 +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.core.common.util
-
-import java.net.URI
-
-actual class CommonUri(private val uri: URI) {
- private val queryParameters: Map> by lazy { parseQueryParameters(uri.rawQuery) }
-
- actual val host: String?
- get() = uri.host
-
- actual val fragment: String?
- get() = uri.fragment
-
- actual val pathSegments: List
- get() = uri.path.orEmpty().split('/').filter { it.isNotBlank() }
-
- actual fun getQueryParameter(key: String): String? = queryParameters[key]?.firstOrNull()
-
- actual fun getBooleanQueryParameter(key: String, defaultValue: Boolean): Boolean {
- val value = getQueryParameter(key) ?: return defaultValue
- return value != "false" && value != "0"
- }
-
- actual override fun toString(): String = uri.toString()
-
- actual companion object {
- actual fun parse(uriString: String): CommonUri = CommonUri(URI(uriString))
- }
-
- fun toUri(): URI = uri
-}
-
-actual fun CommonUri.toPlatformUri(): Any = this.toUri()
diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
index 4b8abdbd3..43ead91a2 100644
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
@@ -17,9 +17,6 @@
package org.meshtastic.core.common.util
import java.net.InetAddress
-import java.net.URLDecoder
-import java.nio.charset.StandardCharsets
-import java.text.DateFormat
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
@@ -76,7 +73,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
- DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
+ shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
}
@Suppress("MagicNumber")
@@ -101,21 +98,6 @@ actual fun String?.isValidAddress(): Boolean {
}
}
-internal fun parseQueryParameters(rawQuery: String?): Map> = rawQuery
- ?.split('&')
- ?.filter { it.isNotBlank() }
- ?.groupBy(
- keySelector = { segment ->
- val key = segment.substringBefore('=', missingDelimiterValue = segment)
- URLDecoder.decode(key, StandardCharsets.UTF_8.name())
- },
- valueTransform = { segment ->
- val value = segment.substringAfter('=', missingDelimiterValue = "")
- URLDecoder.decode(value, StandardCharsets.UTF_8.name())
- },
- )
- .orEmpty()
-
private val IPV4_PATTERN = Regex("^(?:\\d{1,3}\\.){3}\\d{1,3}${'$'}")
private val DOMAIN_PATTERN = Regex("^(?=.{1,253}${'$'})(?:(?!-)[A-Za-z0-9-]{1,63}(?.
+ */
+package org.meshtastic.core.data.manager
+
+import co.touchlab.kermit.Logger
+import kotlinx.atomicfu.atomic
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.PacketHandler
+import org.meshtastic.proto.Heartbeat
+import org.meshtastic.proto.ToRadio
+
+/**
+ * Centralized heartbeat sender for the data layer.
+ *
+ * Consolidates heartbeat nonce management into a single monotonically increasing counter, preventing the firmware's
+ * per-connection duplicate-write filter (byte-level memcmp) from silently dropping consecutive heartbeats.
+ *
+ * This is distinct from [org.meshtastic.core.network.transport.HeartbeatSender], which operates at the transport layer
+ * with raw byte encoding. This class works at the protobuf/data layer through [PacketHandler].
+ */
+@Single
+class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
+ private val nonce = atomic(0)
+
+ /**
+ * Enqueues a heartbeat with a unique nonce.
+ *
+ * @param tag descriptive label for log messages (e.g. "pre-handshake", "inter-stage")
+ */
+ @Suppress("TooGenericExceptionCaught")
+ fun sendHeartbeat(tag: String = "handshake") {
+ try {
+ val n = nonce.incrementAndGet()
+ packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
+ Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
+ }
+ }
+}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
index b0b9e8c5f..628528391 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/HistoryManagerImpl.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
@@ -94,7 +95,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
"lastRequest=$lastRequest window=$window max=$max",
)
- runCatching {
+ safeCatching {
packetHandler.sendToRadio(
MeshPacket(
from = myNodeNum,
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
index 027947453..ab4f3a551 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImpl.kt
@@ -18,12 +18,15 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
+import okio.ByteString
import okio.ByteString.Companion.toByteString
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreExceptionSuspend
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MeshUser
import org.meshtastic.core.model.MessageStatus
@@ -42,6 +45,7 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -60,16 +64,13 @@ class MeshActionHandlerImpl(
private val dataHandler: Lazy,
private val analytics: PlatformAnalytics,
private val meshPrefs: MeshPrefs,
+ private val uiPrefs: UiPrefs,
private val databaseManager: DatabaseManager,
private val notificationManager: NotificationManager,
private val messageProcessor: Lazy,
private val radioConfigRepository: RadioConfigRepository,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshActionHandler {
- private lateinit var scope: CoroutineScope
-
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
companion object {
private const val DEFAULT_REBOOT_DELAY = 5
@@ -95,7 +96,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
- runCatching {
+ safeCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
@@ -202,13 +203,13 @@ class MeshActionHandlerImpl(
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
- val bytes = p.bytes ?: okio.ByteString.EMPTY
+ val bytes = p.bytes ?: ByteString.EMPTY
analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", p.dataType))
}
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
- val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
+ val provideLocation = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
@@ -359,7 +360,7 @@ class MeshActionHandlerImpl(
override fun handleRequestRebootOta(requestId: Int, destNum: Int, mode: Int, hash: ByteArray?) {
val otaMode = OTAMode.fromValue(mode) ?: OTAMode.NO_REBOOT_OTA
val otaEvent =
- AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.ByteString.EMPTY)
+ AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: ByteString.EMPTY)
commandSender.sendAdmin(destNum, requestId) { AdminMessage(ota_request = otaEvent) }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index f492dcd65..cc5cc4319 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -20,17 +20,17 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
-import okio.IOException
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.ConnectionState
+import org.meshtastic.core.model.DeviceVersion
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HandshakeConstants
import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
-import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
@@ -38,9 +38,7 @@ import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.FileInfo
import org.meshtastic.proto.HardwareModel
-import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.NodeInfo
-import org.meshtastic.proto.ToRadio
import org.meshtastic.core.model.MyNodeInfo as SharedMyNodeInfo
import org.meshtastic.proto.MyNodeInfo as ProtoMyNodeInfo
@@ -55,18 +53,14 @@ class MeshConfigFlowManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
- private val packetHandler: PacketHandler,
+ private val heartbeatSender: DataLayerHeartbeatSender,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigFlowManager {
- private lateinit var scope: CoroutineScope
private val wantConfigDelay = 100L
/** Monotonically increasing generation so async clears from a stale handshake are discarded. */
private val handshakeGeneration = atomic(0L)
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
/**
* Type-safe handshake state machine. Each state carries exactly the data that is valid during that phase,
* eliminating the possibility of accessing stale or uninitialized fields.
@@ -84,7 +78,7 @@ class MeshConfigFlowManagerImpl(
* [rawMyNodeInfo] arrives first (my_info packet); [metadata] may arrive shortly after. Both are consumed
* together by [buildMyNodeInfo] at Stage 1 completion.
*/
- data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) :
+ data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, val metadata: DeviceMetadata? = null) :
HandshakeState()
/**
@@ -93,10 +87,8 @@ class MeshConfigFlowManagerImpl(
* [myNodeInfo] was committed at the Stage 1→2 transition. [nodes] accumulates [NodeInfo] packets until
* `config_complete_id` arrives.
*/
- data class ReceivingNodeInfo(
- val myNodeInfo: SharedMyNodeInfo,
- val nodes: MutableList = mutableListOf(),
- ) : HandshakeState()
+ data class ReceivingNodeInfo(val myNodeInfo: SharedMyNodeInfo, val nodes: List = emptyList()) :
+ HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
@@ -142,28 +134,31 @@ class MeshConfigFlowManagerImpl(
return
}
+ // Warn if firmware is below the absolute minimum supported version.
+ // The UI layer already enforces this via FirmwareVersionCheck, so we just log here
+ // for diagnostics rather than hard-disconnecting.
+ finalizedInfo.firmwareVersion?.let { fwVersion ->
+ if (DeviceVersion(fwVersion) < DeviceVersion(DeviceVersion.ABS_MIN_FW_VERSION)) {
+ Logger.w {
+ "Firmware $fwVersion is below minimum ${DeviceVersion.ABS_MIN_FW_VERSION} — " +
+ "protocol incompatibilities may occur"
+ }
+ }
+ }
+
handshakeState = HandshakeState.ReceivingNodeInfo(myNodeInfo = finalizedInfo)
Logger.i { "myNodeInfo committed (nodeNum=${finalizedInfo.myNodeNum})" }
connectionManager.value.onRadioConfigLoaded()
scope.handledLaunch {
delay(wantConfigDelay)
- sendHeartbeat()
+ heartbeatSender.sendHeartbeat("inter-stage")
delay(wantConfigDelay)
Logger.i { "Requesting NodeInfo (Stage 2)" }
connectionManager.value.startNodeInfoOnly()
}
}
- private fun sendHeartbeat() {
- try {
- packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat()))
- Logger.d { "Heartbeat sent between nonce stages" }
- } catch (ex: IOException) {
- Logger.w(ex) { "Failed to send heartbeat; proceeding with node-info stage" }
- }
- }
-
private fun handleNodeInfoComplete(state: HandshakeState.ReceivingNodeInfo) {
Logger.i { "NodeInfo complete (Stage 2)" }
@@ -171,16 +166,12 @@ class MeshConfigFlowManagerImpl(
// Transition state immediately (synchronously) to prevent duplicate handling.
// The async work below (DB writes, broadcasts) proceeds without the guard.
+ // Because nodes is now immutable, no snapshot is needed — state.nodes IS the snapshot.
+ // Any stall-guard retry that re-enters handleNodeInfo will see Complete state and be ignored.
handshakeState = HandshakeState.Complete(myNodeInfo = info)
- // Snapshot and clear immediately so that a concurrent stall-guard retry (which
- // resends want_config_id and causes the firmware to restart the node_info burst)
- // starts accumulating into a fresh list rather than doubling this batch.
- val nodesToProcess = state.nodes.toList()
- state.nodes.clear()
-
val entities =
- nodesToProcess.mapNotNull { nodeInfo ->
+ state.nodes.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
@@ -231,7 +222,7 @@ class MeshConfigFlowManagerImpl(
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
val state = handshakeState
if (state is HandshakeState.ReceivingConfig) {
- state.metadata = metadata
+ handshakeState = state.copy(metadata = metadata)
// Persist the metadata immediately — buildMyNodeInfo() reads it at Stage 1 complete,
// but the DB write does not need to wait until then.
if (metadata != DeviceMetadata()) {
@@ -245,7 +236,7 @@ class MeshConfigFlowManagerImpl(
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
- state.nodes.add(info)
+ handshakeState = state.copy(nodes = state.nodes + info)
} else {
Logger.w { "Ignoring NodeInfo outside Stage 2 (state=$state)" }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
index 06d973204..b622cedbf 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImpl.kt
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.repository.MeshConfigHandler
@@ -40,8 +41,8 @@ class MeshConfigHandlerImpl(
private val radioConfigRepository: RadioConfigRepository,
private val serviceRepository: ServiceRepository,
private val nodeManager: NodeManager,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConfigHandler {
- private lateinit var scope: CoroutineScope
private val _localConfig = MutableStateFlow(LocalConfig())
override val localConfig = _localConfig.asStateFlow()
@@ -49,8 +50,7 @@ class MeshConfigHandlerImpl(
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
override val moduleConfig = _moduleConfig.asStateFlow()
- override fun start(scope: CoroutineScope) {
- this.scope = scope
+ init {
radioConfigRepository.localConfigFlow.onEach { _localConfig.value = it }.launchIn(scope)
radioConfigRepository.moduleConfigFlow.onEach { _moduleConfig.value = it }.launchIn(scope)
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
index 5954b579c..022f3548d 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt
@@ -19,13 +19,16 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import okio.ByteString
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@@ -57,6 +60,7 @@ import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Config
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.ToRadio
+import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit
@@ -81,17 +85,26 @@ class MeshConnectionManagerImpl(
private val packetRepository: PacketRepository,
private val workerManager: MeshWorkerManager,
private val appWidgetUpdater: AppWidgetUpdater,
+ private val heartbeatSender: DataLayerHeartbeatSender,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshConnectionManager {
- private lateinit var scope: CoroutineScope
+ /**
+ * Serializes [onConnectionChanged] to prevent TOCTOU races when multiple coroutines emit state transitions
+ * concurrently (e.g. flow collector vs. sleep-timeout coroutine).
+ */
+ private val connectionMutex = Mutex()
+
+ private var preHandshakeJob: Job? = null
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
private var connectionRestored = false
- @OptIn(FlowPreview::class)
- override fun start(scope: CoroutineScope) {
- this.scope = scope
+ init {
+ // Bridge transport-level state into the canonical app-level state.
+ // This is the ONLY consumer of RadioInterfaceService.connectionState — it applies
+ // light-sleep policy and handshake awareness before writing to ServiceRepository.
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
@@ -125,6 +138,13 @@ class MeshConnectionManagerImpl(
.launchIn(scope)
}
+ /**
+ * Bridges a transport-level [ConnectionState] into the canonical app-level state.
+ *
+ * Applies light-sleep policy (power-saving / router role) to decide whether a [ConnectionState.DeviceSleep] event
+ * should be surfaced as sleep or as a full disconnect, then delegates to [onConnectionChanged] for the actual state
+ * transition.
+ */
private suspend fun onRadioConnectionState(newState: ConnectionState) {
val localConfig = radioConfigRepository.localConfigFlow.first()
val isRouter = localConfig.device?.role == Config.DeviceConfig.Role.ROUTER
@@ -141,20 +161,22 @@ class MeshConnectionManagerImpl(
onConnectionChanged(effectiveState)
}
- private fun onConnectionChanged(c: ConnectionState) {
+ private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock {
val current = serviceRepository.connectionState.value
- if (current == c) return
+ if (current == c) return@withLock
// If the transport reports 'Connected', but we are already in the middle of a handshake (Connecting)
if (c is ConnectionState.Connected && current is ConnectionState.Connecting) {
Logger.d { "Ignoring redundant transport connection signal while handshake is in progress" }
- return
+ return@withLock
}
Logger.i { "onConnectionChanged: $current -> $c" }
sleepTimeout?.cancel()
sleepTimeout = null
+ preHandshakeJob?.cancel()
+ preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -175,16 +197,26 @@ class MeshConnectionManagerImpl(
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
- Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
- startConfigOnly()
+
+ // Send a wake-up heartbeat before the config request. The firmware may be in a
+ // power-saving state where the NimBLE callback context needs warming up. The 100ms
+ // delay ensures the heartbeat BLE write is enqueued before the want_config_id
+ // (sendToRadio is fire-and-forget through async coroutine launches).
+ preHandshakeJob =
+ scope.handledLaunch {
+ heartbeatSender.sendHeartbeat("pre-handshake")
+ delay(PRE_HANDSHAKE_SETTLE_MS)
+ Logger.i { "Starting mesh handshake (Stage 1)" }
+ startConfigOnly()
+ }
}
- private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
+ private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
- delay(HANDSHAKE_TIMEOUT)
+ delay(timeout)
if (serviceRepository.connectionState.value is ConnectionState.Connecting) {
// Attempt one retry. Note: the firmware silently drops identical consecutive
// writes (per-connection dedup). If the first want_config_id was received and
@@ -205,7 +237,7 @@ class MeshConnectionManagerImpl(
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
- commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect.
+ commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect.
locationManager.stop()
mqttManager.stop()
}
@@ -260,19 +292,19 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
- startHandshakeStallGuard(1, action)
+ startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
- startHandshakeStallGuard(2, action)
+ startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
action()
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
- val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
+ val queuedPackets = packetRepository.getQueuedPackets()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
@@ -302,8 +334,7 @@ class MeshConnectionManagerImpl(
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
- mqttManager.start(
- scope,
+ mqttManager.startProxy(
moduleConfig.mqtt?.enabled == true,
moduleConfig.mqtt?.proxy_to_client_enabled == true,
)
@@ -350,11 +381,12 @@ class MeshConnectionManagerImpl(
updateStatusNotification(t)
}
- override fun updateStatusNotification(telemetry: Telemetry?): Any =
+ override fun updateStatusNotification(telemetry: Telemetry?) {
serviceNotifications.updateServiceStateNotification(
serviceRepository.connectionState.value,
telemetry = telemetry,
)
+ }
companion object {
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
@@ -364,7 +396,23 @@ class MeshConnectionManagerImpl(
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
- private val HANDSHAKE_TIMEOUT = 30.seconds
+ /**
+ * Delay between the pre-handshake heartbeat and the want_config_id send.
+ *
+ * Ensures the heartbeat BLE write completes and the firmware's NimBLE callback context is warmed up before the
+ * config request arrives. 100ms is well above observed ESP32 task scheduling latency (~10–50ms) while adding
+ * negligible connection latency.
+ */
+ private const val PRE_HANDSHAKE_SETTLE_MS = 100L
+
+ private val HANDSHAKE_TIMEOUT_STAGE1 = 30.seconds
+
+ /**
+ * Stage 2 drains the full node database, which can be significantly larger than Stage 1 config on big meshes.
+ * 60 s matches the meshtastic-client SDK timeout and avoids premature stall-guard triggers on meshes with 50+
+ * nodes.
+ */
+ private val HANDSHAKE_TIMEOUT_STAGE2 = 60.seconds
// Shorter window for the retry attempt: if the device genuinely didn't receive the
// first want_config_id the retry completes within a few seconds. Waiting another 30s
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
index 07521b21c..384f722d8 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt
@@ -22,6 +22,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
+import okio.ByteString
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@@ -94,14 +96,8 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
- private lateinit var scope: CoroutineScope
-
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- storeForwardHandler.start(scope)
- telemetryHandler.start(scope)
- }
private val rememberDataType =
setOf(
@@ -252,7 +248,7 @@ class MeshDataHandlerImpl(
val payload = packet.decoded?.payload ?: return
val u =
User.ADAPTER.decode(payload)
- .let { if (it.is_licensed == true) it.copy(public_key = okio.ByteString.EMPTY) else it }
+ .let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
.let {
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
it.copy(long_name = "${it.long_name} (MQTT)")
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
index 9fd28ecb4..d9d21ad8b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@@ -31,6 +32,8 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
+import org.meshtastic.core.model.util.toOneLineString
+import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
@@ -53,8 +56,8 @@ class MeshMessageProcessorImpl(
private val meshLogRepository: Lazy,
private val router: Lazy,
private val fromRadioDispatcher: FromRadioPacketHandler,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MeshMessageProcessor {
- private lateinit var scope: CoroutineScope
private val mapsMutex = Mutex()
private val logUuidByPacketId = mutableMapOf()
@@ -68,15 +71,14 @@ class MeshMessageProcessorImpl(
@Volatile private var lastLocalNodeRefreshMs = 0L
private val earlyMutex = Mutex()
- private val earlyReceivedPackets = kotlin.collections.ArrayDeque()
+ private val earlyReceivedPackets = ArrayDeque()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {
scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } }
}
- override fun start(scope: CoroutineScope) {
- this.scope = scope
+ init {
nodeManager.isNodeDbReady
.onEach { ready ->
if (ready) {
@@ -96,7 +98,7 @@ class MeshMessageProcessorImpl(
}
.onFailure { _ ->
Logger.e(primaryException) {
- "Failed to parse radio packet (len=${bytes.size}). " + "Not a valid FromRadio or LogRecord."
+ "Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
}
}
}
@@ -125,11 +127,11 @@ class MeshMessageProcessorImpl(
proto.xmodemPacket != null -> "XmodemPacket" to proto.xmodemPacket.toString()
proto.deviceuiConfig != null -> "DeviceUIConfig" to proto.deviceuiConfig.toString()
proto.fileInfo != null -> "FileInfo" to proto.fileInfo.toString()
- proto.my_info != null -> "MyInfo" to proto.my_info.toString()
- proto.node_info != null -> "NodeInfo" to proto.node_info.toString()
- proto.config != null -> "Config" to proto.config.toString()
- proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig.toString()
- proto.channel != null -> "Channel" to proto.channel.toString()
+ proto.my_info != null -> "MyInfo" to proto.my_info!!.toOneLineString()
+ proto.node_info != null -> "NodeInfo" to proto.node_info!!.toPIIString()
+ proto.config != null -> "Config" to proto.config!!.toOneLineString()
+ proto.moduleConfig != null -> "ModuleConfig" to proto.moduleConfig!!.toOneLineString()
+ proto.channel != null -> "Channel" to proto.channel!!.toOneLineString()
proto.clientNotification != null -> "ClientNotification" to proto.clientNotification.toString()
else -> return
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
index aaf109be9..8973589bd 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshRouterImpl.kt
@@ -16,7 +16,6 @@
*/
package org.meshtastic.core.data.manager
-import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshConfigFlowManager
@@ -64,13 +63,4 @@ class MeshRouterImpl(
override val xmodemManager: XModemManager
get() = xmodemManagerLazy.value
-
- override fun start(scope: CoroutineScope) {
- dataHandler.start(scope)
- configHandler.start(scope)
- tracerouteHandler.start(scope)
- neighborInfoHandler.start(scope)
- configFlowManager.start(scope)
- actionHandler.start(scope)
- }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
index 969b67a2f..5693d343b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MqttManagerImpl.kt
@@ -20,14 +20,28 @@ import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.stateIn
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
+import org.meshtastic.core.model.MqttConnectionState
+import org.meshtastic.core.model.MqttProbeStatus
import org.meshtastic.core.network.repository.MQTTRepository
+import org.meshtastic.core.network.repository.resolveEndpoint
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.mqtt.ConnectionState
+import org.meshtastic.mqtt.MqttClient
+import org.meshtastic.mqtt.MqttException
+import org.meshtastic.mqtt.ProbeResult
+import org.meshtastic.mqtt.probe
import org.meshtastic.proto.MqttClientProxyMessage
import org.meshtastic.proto.ToRadio
@@ -36,22 +50,33 @@ class MqttManagerImpl(
private val mqttRepository: MQTTRepository,
private val packetHandler: PacketHandler,
private val serviceRepository: ServiceRepository,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : MqttManager {
- private lateinit var scope: CoroutineScope
private var mqttMessageFlow: Job? = null
+ private val proxyActive = MutableStateFlow(false)
- override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
- this.scope = scope
+ override val mqttConnectionState: StateFlow =
+ combine(proxyActive, mqttRepository.connectionState) { active, libState ->
+ if (!active) MqttConnectionState.Inactive else libState.toAppState()
+ }
+ .stateIn(scope, SharingStarted.Eagerly, MqttConnectionState.Inactive)
+
+ override fun startProxy(enabled: Boolean, proxyToClientEnabled: Boolean) {
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
+ proxyActive.value = true
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable ->
- serviceRepository.setErrorMessage(
- text = "MqttClientProxy failed: $throwable",
- severity = Severity.Warn,
- )
+ proxyActive.value = false
+ val message =
+ when (throwable) {
+ is MqttException.ConnectionRejected -> "MQTT: connection rejected (check credentials)"
+ is MqttException.ConnectionLost -> "MQTT: connection lost"
+ else -> "MQTT proxy failed: ${throwable.message}"
+ }
+ serviceRepository.setErrorMessage(text = message, severity = Severity.Warn)
}
.launchIn(scope)
}
@@ -63,6 +88,7 @@ class MqttManagerImpl(
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
+ proxyActive.value = false
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
@@ -79,4 +105,57 @@ class MqttManagerImpl(
else -> {}
}
}
+
+ private fun ConnectionState.toAppState(): MqttConnectionState = when (this) {
+ is ConnectionState.Connecting -> MqttConnectionState.Connecting
+ is ConnectionState.Connected -> MqttConnectionState.Connected
+ is ConnectionState.Reconnecting ->
+ MqttConnectionState.Reconnecting(attempt = attempt, lastError = lastError?.message)
+ is ConnectionState.Disconnected ->
+ reason?.let { MqttConnectionState.Disconnected(reason = it.message) }
+ ?: MqttConnectionState.Disconnected.Idle
+ }
+
+ override suspend fun probe(
+ address: String,
+ tlsEnabled: Boolean,
+ username: String?,
+ password: String?,
+ ): MqttProbeStatus {
+ val endpoint = resolveEndpoint(address, tlsEnabled)
+ val result =
+ MqttClient.probe(endpoint = endpoint) {
+ val user = username?.takeUnless { it.isEmpty() }
+ val pass = password?.takeUnless { it.isEmpty() }
+ if (user != null) this.username = user
+ if (pass != null) password(pass)
+ }
+ return result.toAppStatus()
+ }
+
+ private fun ProbeResult.toAppStatus(): MqttProbeStatus = when (this) {
+ is ProbeResult.Success -> {
+ val info = serverInfo
+ val summary =
+ buildList {
+ info.assignedClientIdentifier?.let { add("client=$it") }
+ info.maximumQosOrdinal?.let { add("maxQoS=$it") }
+ info.serverKeepAliveSeconds?.let { add("keepalive=${it}s") }
+ }
+ .joinToString(", ")
+ .ifEmpty { null }
+ MqttProbeStatus.Success(serverInfo = summary)
+ }
+ is ProbeResult.Rejected ->
+ MqttProbeStatus.Rejected(
+ reasonCode = reasonCode.value,
+ reason = message,
+ serverReference = serverReference,
+ )
+ is ProbeResult.DnsFailure -> MqttProbeStatus.DnsFailure(message = cause.message)
+ is ProbeResult.TcpFailure -> MqttProbeStatus.TcpFailure(message = cause.message)
+ is ProbeResult.TlsFailure -> MqttProbeStatus.TlsFailure(message = cause.message)
+ is ProbeResult.Timeout -> MqttProbeStatus.Timeout(timeoutMs = durationMs)
+ is ProbeResult.Other -> MqttProbeStatus.Other(message = cause.message)
+ }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
index 4019e5a9b..3f483ba25 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt
@@ -20,7 +20,6 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
-import kotlinx.coroutines.CoroutineScope
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.nowMillis
@@ -37,16 +36,11 @@ class NeighborInfoHandlerImpl(
private val serviceRepository: ServiceRepository,
private val serviceBroadcasts: ServiceBroadcasts,
) : NeighborInfoHandler {
- private lateinit var scope: CoroutineScope
private val startTimes = atomic(persistentMapOf())
override var lastNeighborInfo: NeighborInfo? = null
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 9ce4ba05d..fe6d22f4c 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -24,6 +24,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import okio.ByteString
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
@@ -59,8 +60,8 @@ class NodeManagerImpl(
private val nodeRepository: NodeRepository,
private val serviceBroadcasts: ServiceBroadcasts,
private val notificationManager: NotificationManager,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : NodeManager {
- private lateinit var scope: CoroutineScope
private val _nodeDBbyNodeNum = atomic(persistentMapOf())
private val _nodeDBbyID = atomic(persistentMapOf())
@@ -88,10 +89,6 @@ class NodeManagerImpl(
myNodeNum.value = num
}
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
companion object {
private const val TIME_MS_TO_S = 1000L
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
index 1d4d11adc..e2e9a8432 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/PacketHandlerImpl.kt
@@ -22,12 +22,15 @@ import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
@@ -60,6 +63,7 @@ class PacketHandlerImpl(
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy,
private val serviceRepository: ServiceRepository,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : PacketHandler {
companion object {
@@ -67,11 +71,15 @@ class PacketHandlerImpl(
}
private var queueJob: Job? = null
- private lateinit var scope: CoroutineScope
private val queueMutex = Mutex()
private val queuedPackets = mutableListOf()
+ // Unbounded channel preserves FIFO ordering of fire-and-forget sendToRadio(MeshPacket)
+ // calls. The non-suspend entry point does trySend (always succeeds for UNLIMITED) and
+ // a single consumer coroutine enqueues packets under queueMutex in arrival order.
+ private val outboundChannel = Channel(Channel.UNLIMITED)
+
// Set to true by stopPacketQueue() under queueMutex. Checked by startPacketQueueLocked()
// and the queue processor's finally block to prevent restarting a stopped queue.
private var queueStopped = false
@@ -79,9 +87,18 @@ class PacketHandlerImpl(
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf>()
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- queueStopped = false // Safe: called before any concurrent operations on this scope.
+ init {
+ // Single consumer serializes enqueues from the non-suspend sendToRadio(MeshPacket)
+ // entry point, preserving FIFO across rapid concurrent callers.
+ scope.launch {
+ outboundChannel.consumeAsFlow().collect { packet ->
+ queueMutex.withLock {
+ queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
+ queuedPackets.add(packet)
+ startPacketQueueLocked()
+ }
+ }
+ }
}
override fun sendToRadio(p: ToRadio) {
@@ -108,13 +125,9 @@ class PacketHandlerImpl(
}
override fun sendToRadio(packet: MeshPacket) {
- scope.launch {
- queueMutex.withLock {
- queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
- queuedPackets.add(packet)
- startPacketQueueLocked()
- }
- }
+ // Non-suspend entry point — order-preserving via unbounded channel drained by
+ // a single consumer coroutine. trySend on UNLIMITED never fails for capacity.
+ outboundChannel.trySend(packet)
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
index 4f71879ce..e8ab4eeb7 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImpl.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import okio.ByteString.Companion.toByteString
import okio.IOException
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.DataPacket
@@ -45,12 +46,8 @@ class StoreForwardPacketHandlerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val historyManager: HistoryManager,
private val dataHandler: Lazy,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : StoreForwardPacketHandler {
- private lateinit var scope: CoroutineScope
-
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
override fun handleStoreAndForward(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
index 205dd30e2..4887ff19b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
@@ -49,16 +50,12 @@ class TelemetryPacketHandlerImpl(
private val nodeManager: NodeManager,
private val connectionManager: Lazy,
private val notificationManager: NotificationManager,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : TelemetryPacketHandler {
- private lateinit var scope: CoroutineScope
private val batteryMutex = Mutex()
private val batteryPercentCooldowns = mutableMapOf()
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) {
val payload = packet.decoded?.payload ?: return
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
index 5e8d954f6..5d2feb65e 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TracerouteHandlerImpl.kt
@@ -22,6 +22,7 @@ import kotlinx.atomicfu.update
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
+import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.common.util.handledLaunch
@@ -42,15 +43,11 @@ class TracerouteHandlerImpl(
private val serviceRepository: ServiceRepository,
private val tracerouteSnapshotRepository: TracerouteSnapshotRepository,
private val nodeRepository: NodeRepository,
+ @Named("ServiceScope") private val scope: CoroutineScope,
) : TracerouteHandler {
- private lateinit var scope: CoroutineScope
private val startTimes = atomic(persistentMapOf())
- override fun start(scope: CoroutineScope) {
- this.scope = scope
- }
-
override fun recordStartTime(requestId: Int) {
startTimes.update { it.put(requestId, nowMillis) }
}
@@ -68,7 +65,7 @@ class TracerouteHandlerImpl(
routeDiscovery.getTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
- ?: "Unknown" // TODO: Use core:resources once available in core:data
+ ?: "Unknown"
},
headerTowards = "Route towards destination:",
headerBack = "Route back to us:",
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 338a0d6ea..fdcc6d344 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -20,6 +20,7 @@ import co.touchlab.kermit.Logger
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
@@ -98,7 +99,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
- runCatching {
+ safeCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@@ -157,7 +158,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List,
- ): Result = runCatching {
+ ): Result = safeCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d {
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
index a47a5381f..8f3154815 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt
@@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
@@ -97,7 +98,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
- runCatching {
+ safeCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@@ -110,7 +111,7 @@ open class FirmwareReleaseRepositoryImpl(
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
- runCatching {
+ safeCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
index f6a49f190..149c62d2b 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/PacketRepositoryImpl.kt
@@ -28,6 +28,8 @@ import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
+import org.meshtastic.core.database.dao.NodeInfoDao
+import org.meshtastic.core.database.entity.PacketEntity
import org.meshtastic.core.database.entity.toReaction
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactSettings
@@ -108,7 +110,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
dao.upsertContactSettings(listOf(updated))
}
- override suspend fun getQueuedPackets(): List? =
+ override suspend fun getQueuedPackets(): List =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getQueuedPackets() }
suspend fun insertRoomPacket(packet: RoomPacket) =
@@ -154,13 +156,14 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
else -> dao.getMessagesFrom(contact)
}
flow.mapLatest { packets ->
+ val cachedGetNode = memoize(getNode)
+ val replyIds = packets.mapNotNull { it.packet.data.replyId?.takeIf { id -> id != 0 } }.distinct()
+ val replyMap = batchGetPacketsByIds(replyIds)
packets.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage = replyId?.let { replyMap[it] }?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
}
@@ -177,13 +180,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -204,13 +210,16 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
)
.flow
.map { pagingData ->
+ val cachedGetNode = memoize(getNode)
+ val replyCache = mutableMapOf()
pagingData.map { packet ->
- val message = packet.toMessage(getNode)
- message.replyId
- .takeIf { it != null && it != 0 }
- ?.let { getPacketByPacketIdInternal(it) }
- ?.let { originalPacket -> originalPacket.toMessage(getNode) }
- ?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
+ val message = packet.toMessage(cachedGetNode)
+ val replyId = message.replyId?.takeIf { it != 0 }
+ val originalMessage =
+ replyId
+ ?.let { id -> replyCache.getOrPut(id) { getPacketByPacketIdInternal(id) } }
+ ?.toMessage(cachedGetNode)
+ if (originalMessage != null) message.copy(originalMessage = originalMessage) else message
}
}
@@ -230,6 +239,22 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
private suspend fun getPacketByPacketIdInternal(packetId: Int) =
withContext(dispatchers.io) { dbManager.currentDb.value.packetDao().getPacketByPacketId(packetId) }
+ private suspend fun batchGetPacketsByIds(ids: List): Map = if (ids.isEmpty()) {
+ emptyMap()
+ } else {
+ withContext(dispatchers.io) {
+ val dao = dbManager.currentDb.value.packetDao()
+ ids.chunked(NodeInfoDao.MAX_BIND_PARAMS)
+ .flatMap { dao.getPacketsByPacketIds(it) }
+ .associateBy { it.packet.packetId }
+ }
+ }
+
+ private fun memoize(getNode: suspend (String?) -> Node): suspend (String?) -> Node {
+ val cache = mutableMapOf()
+ return { id -> cache.getOrPut(id) { getNode(id) } }
+ }
+
override suspend fun insert(
packet: DataPacket,
myNodeNum: Int,
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
index 6ac094e48..5b29e9f26 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshActionHandlerImplTest.kt
@@ -25,6 +25,7 @@ import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode.Companion.not
import dev.mokkery.verifySuspend
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -46,6 +47,7 @@ import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.PlatformAnalytics
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceBroadcasts
+import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.proto.AdminMessage
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
@@ -67,6 +69,7 @@ class MeshActionHandlerImplTest {
private val dataHandler = mock(MockMode.autofill)
private val analytics = mock(MockMode.autofill)
private val meshPrefs = mock(MockMode.autofill)
+ private val uiPrefs = mock(MockMode.autofill)
private val databaseManager = mock(MockMode.autofill)
private val notificationManager = mock(MockMode.autofill)
private val messageProcessor = mock(MockMode.autofill)
@@ -89,28 +92,29 @@ class MeshActionHandlerImplTest {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
-
- handler =
- MeshActionHandlerImpl(
- nodeManager = nodeManager,
- commandSender = commandSender,
- packetRepository = lazy { packetRepository },
- serviceBroadcasts = serviceBroadcasts,
- dataHandler = lazy { dataHandler },
- analytics = analytics,
- meshPrefs = meshPrefs,
- databaseManager = databaseManager,
- notificationManager = notificationManager,
- messageProcessor = lazy { messageProcessor },
- radioConfigRepository = radioConfigRepository,
- )
}
+ private fun createHandler(scope: CoroutineScope): MeshActionHandlerImpl = MeshActionHandlerImpl(
+ nodeManager = nodeManager,
+ commandSender = commandSender,
+ packetRepository = lazy { packetRepository },
+ serviceBroadcasts = serviceBroadcasts,
+ dataHandler = lazy { dataHandler },
+ analytics = analytics,
+ meshPrefs = meshPrefs,
+ uiPrefs = uiPrefs,
+ databaseManager = databaseManager,
+ notificationManager = notificationManager,
+ messageProcessor = lazy { messageProcessor },
+ radioConfigRepository = radioConfigRepository,
+ scope = scope,
+ )
+
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -128,7 +132,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
@@ -141,7 +145,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -156,7 +160,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
@@ -168,7 +172,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -187,7 +191,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
@@ -201,7 +205,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
@@ -213,7 +217,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
@@ -227,7 +231,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
@@ -242,7 +246,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
@@ -256,7 +260,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
@@ -268,7 +272,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
@@ -281,7 +285,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
@@ -296,7 +300,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
@@ -311,7 +315,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
- handler.start(testScope)
+ handler = createHandler(testScope)
val meshUser =
MeshUser(
id = "!12345678",
@@ -331,7 +335,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
- handler.start(testScope)
+ handler = createHandler(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
@@ -345,7 +349,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_sameNode_doesNothing() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
@@ -354,8 +358,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
- handler.start(testScope)
- every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
+ handler = createHandler(testScope)
+ every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
@@ -365,8 +369,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
- handler.start(testScope)
- every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
+ handler = createHandler(testScope)
+ every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
@@ -378,8 +382,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
- handler.start(testScope)
- every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
+ handler = createHandler(testScope)
+ every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
@@ -392,7 +396,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
@@ -409,7 +413,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
@@ -425,7 +429,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
@@ -442,7 +446,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
@@ -457,7 +461,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetChannel_nullPayload_doesNothing() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
@@ -468,7 +472,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
@@ -480,7 +484,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
- handler.start(testScope)
+ handler = createHandler(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = User.ADAPTER.encode(user)
@@ -495,7 +499,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
@@ -504,7 +508,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
@@ -515,7 +519,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
@@ -524,7 +528,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
- handler.start(testScope)
+ handler = createHandler(testScope)
val channel = Channel(index = 2)
val payload = Channel.ADAPTER.encode(channel)
@@ -538,7 +542,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
@@ -547,7 +551,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
- handler.start(testScope)
+ handler = createHandler(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
@@ -559,7 +563,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
- handler.start(testScope)
+ handler = createHandler(testScope)
handler.handleRequestNodedbReset(1, REMOTE_NODE_NUM, preserveFavorites = true)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
index 9580d5363..fdcd8ed44 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
+import dev.mokkery.answering.calls
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
@@ -97,9 +98,9 @@ class MeshConfigFlowManagerImplTest {
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
- packetHandler = packetHandler,
+ heartbeatSender = DataLayerHeartbeatSender(packetHandler),
+ scope = testScope,
)
- manager.start(testScope)
}
// ---------- handleMyInfo ----------
@@ -174,6 +175,49 @@ class MeshConfigFlowManagerImplTest {
verify { connectionManager.startNodeInfoOnly() }
}
+ @Test
+ fun `Stage 1 complete sends heartbeat with non-zero nonce between stages`() = testScope.runTest {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(metadata)
+ advanceUntilIdle()
+
+ sentPackets.clear() // Clear any packets from prior phases
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ val heartbeats = sentPackets.filter { it.heartbeat != null }
+ assertEquals(1, heartbeats.size, "Expected exactly one inter-stage heartbeat")
+ assertEquals(
+ true,
+ heartbeats[0].heartbeat!!.nonce != 0,
+ "Inter-stage heartbeat should have a non-zero nonce",
+ )
+ }
+
+ @Test
+ fun `Stage 1 complete with old firmware logs warning but continues handshake`() = testScope.runTest {
+ val oldMetadata =
+ DeviceMetadata(firmware_version = "2.3.0", hw_model = HardwareModel.HELTEC_V3, hasWifi = false)
+ manager.handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(oldMetadata)
+ advanceUntilIdle()
+
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceUntilIdle()
+
+ // Handshake should still progress despite old firmware
+ verify { connectionManager.onRadioConfigLoaded() }
+ verify { connectionManager.startNodeInfoOnly() }
+ }
+
@Test
fun `Stage 1 complete without metadata still succeeds with null firmware version`() = testScope.runTest {
manager.handleMyInfo(protoMyNodeInfo)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
index b71942d0e..bf3247815 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigHandlerImplTest.kt
@@ -23,6 +23,7 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -60,20 +61,20 @@ class MeshConfigHandlerImplTest {
fun setUp() {
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
-
- handler =
- MeshConfigHandlerImpl(
- radioConfigRepository = radioConfigRepository,
- serviceRepository = serviceRepository,
- nodeManager = nodeManager,
- )
}
+ private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl(
+ radioConfigRepository = radioConfigRepository,
+ serviceRepository = serviceRepository,
+ nodeManager = nodeManager,
+ scope = scope,
+ )
+
// ---------- start and flow wiring ----------
@Test
fun `start wires localConfig flow from repository`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
localConfigFlow.value = config
advanceUntilIdle()
@@ -83,7 +84,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
moduleConfigFlow.value = config
advanceUntilIdle()
@@ -95,7 +96,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
handler.handleDeviceConfig(config)
advanceUntilIdle()
@@ -106,7 +107,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val configs =
listOf(
Config(position = Config.PositionConfig()),
@@ -131,7 +132,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
handler.handleModuleConfig(config)
advanceUntilIdle()
@@ -142,7 +143,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val myNum = 123
every { nodeManager.myNodeNum } returns MutableStateFlow(myNum)
@@ -155,7 +156,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
@@ -168,7 +169,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel persists channel settings`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val channel = Channel(index = 0)
handler.handleChannel(channel)
advanceUntilIdle()
@@ -178,7 +179,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns
MyNodeInfo(
myNodeNum = 123,
@@ -206,7 +207,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns null
val channel = Channel(index = 0)
@@ -220,7 +221,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) {
- handler.start(backgroundScope)
+ handler = createHandler(backgroundScope)
val config = DeviceUIConfig()
handler.handleDeviceUIConfig(config)
advanceUntilIdle()
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
index 5263254d3..07c8914ad 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImplTest.kt
@@ -24,9 +24,10 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
-import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
@@ -60,7 +61,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
-@OptIn(ExperimentalCoroutinesApi::class)
+@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
class MeshConnectionManagerImplTest {
private val radioInterfaceService = mock(MockMode.autofill)
private val serviceRepository = mock(MockMode.autofill)
@@ -108,37 +109,35 @@ class MeshConnectionManagerImplTest {
every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { packetHandler.sendToRadio(any()) } returns Unit
-
- manager =
- MeshConnectionManagerImpl(
- radioInterfaceService,
- serviceRepository,
- serviceBroadcasts,
- serviceNotifications,
- uiPrefs,
- packetHandler,
- nodeRepository,
- locationManager,
- mqttManager,
- historyManager,
- radioConfigRepository,
- commandSender,
- nodeManager,
- analytics,
- packetRepository,
- workerManager,
- appWidgetUpdater,
- )
}
- @AfterTest fun tearDown() {}
+ private fun createManager(scope: CoroutineScope): MeshConnectionManagerImpl = MeshConnectionManagerImpl(
+ radioInterfaceService,
+ serviceRepository,
+ serviceBroadcasts,
+ serviceNotifications,
+ uiPrefs,
+ packetHandler,
+ nodeRepository,
+ locationManager,
+ mqttManager,
+ historyManager,
+ radioConfigRepository,
+ commandSender,
+ nodeManager,
+ analytics,
+ packetRepository,
+ workerManager,
+ appWidgetUpdater,
+ DataLayerHeartbeatSender(packetHandler),
+ scope,
+ )
+
+ @AfterTest fun tearDown() = Unit
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
-
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
@@ -151,19 +150,62 @@ class MeshConnectionManagerImplTest {
}
@Test
- fun `Disconnected state stops services`() = runTest(testDispatcher) {
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- every { packetHandler.stopPacketQueue() } returns Unit
- every { locationManager.stop() } returns Unit
- every { mqttManager.stop() } returns Unit
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- every { packetHandler.stopPacketQueue() } returns Unit
- every { locationManager.stop() } returns Unit
- every { mqttManager.stop() } returns Unit
+ fun `Connected state sends pre-handshake heartbeat before config request`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance past PRE_HANDSHAKE_SETTLE_MS (100ms) but NOT the 30s stall guard timeout
+ advanceTimeBy(200)
+
+ // First ToRadio should be a heartbeat, second should be want_config_id
+ assertEquals(2, sentPackets.size, "Expected heartbeat + want_config_id, got ${sentPackets.size} packets")
+ val heartbeat = sentPackets[0]
+ val wantConfig = sentPackets[1]
+
+ assertEquals(true, heartbeat.heartbeat != null, "First packet should be a heartbeat")
+ assertEquals(true, heartbeat.heartbeat!!.nonce != 0, "Heartbeat should have a non-zero nonce")
+ assertEquals(
+ org.meshtastic.core.repository.HandshakeConstants.CONFIG_NONCE,
+ wantConfig.want_config_id,
+ "Second packet should be want_config_id with CONFIG_NONCE",
+ )
+ }
+
+ @Test
+ fun `Disconnect during pre-handshake settle cancels config start`() = runTest(testDispatcher) {
+ val sentPackets = mutableListOf()
+ every { packetHandler.sendToRadio(any()) } calls
+ { call ->
+ sentPackets.add(call.arg(0))
+ }
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
- manager.start(backgroundScope)
+
+ manager = createManager(backgroundScope)
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance only 50ms — within the 100ms settle window
+ advanceTimeBy(50)
+
+ // Should have sent only the heartbeat so far, not want_config_id
+ assertEquals(1, sentPackets.size, "Only heartbeat should be sent before settle completes")
+
+ // Disconnect before the settle delay completes — should cancel the pending config start
+ radioConnectionState.value = ConnectionState.Disconnected
+ advanceTimeBy(200)
+
+ // The want_config_id should NOT have been sent because the job was cancelled
+ val configPackets = sentPackets.filter { it.want_config_id != null }
+ assertEquals(0, configPackets.size, "want_config_id should not be sent after disconnect")
+ }
+
+ @Test
+ fun `Disconnected state stops services`() = runTest(testDispatcher) {
+ every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
+ manager = createManager(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
@@ -190,14 +232,9 @@ class MeshConnectionManagerImplTest {
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- every { packetHandler.stopPacketQueue() } returns Unit
- every { locationManager.stop() } returns Unit
- every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
@@ -215,13 +252,8 @@ class MeshConnectionManagerImplTest {
// Power saving enabled
val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- every { packetHandler.stopPacketQueue() } returns Unit
- every { locationManager.stop() } returns Unit
- every { mqttManager.stop() } returns Unit
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
@@ -236,7 +268,7 @@ class MeshConnectionManagerImplTest {
@Test
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
val packetId = 456
everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
every { workerManager.enqueueSendMessage(any()) } returns Unit
@@ -257,15 +289,15 @@ class MeshConnectionManagerImplTest {
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
- every { mqttManager.start(any(), any(), any()) } returns Unit
+ every { mqttManager.startProxy(any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
manager.onNodeDbReady()
advanceUntilIdle()
- verify { mqttManager.start(any(), true, true) }
+ verify { mqttManager.startProxy(true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
@@ -279,14 +311,9 @@ class MeshConnectionManagerImplTest {
device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER),
)
every { radioConfigRepository.localConfigFlow } returns flowOf(config)
- every { packetHandler.sendToRadio(any()) } returns Unit
- every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
- every { packetHandler.stopPacketQueue() } returns Unit
- every { locationManager.stop() } returns Unit
- every { mqttManager.stop() } returns Unit
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
- manager.start(backgroundScope)
+ manager = createManager(backgroundScope)
advanceUntilIdle()
// Transition to Connected then DeviceSleep
@@ -310,4 +337,92 @@ class MeshConnectionManagerImplTest {
"Should transition to Disconnected after capped timeout (300s), not the raw 3630s",
)
}
+
+ @Test
+ fun `rapid state transitions are serialized by connectionMutex`() = runTest(testDispatcher) {
+ // Power saving enabled so DeviceSleep is preserved (not mapped to Disconnected)
+ val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true))
+ every { radioConfigRepository.localConfigFlow } returns flowOf(config)
+ every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
+
+ // Record every state transition so we can verify ordering
+ val observed = mutableListOf()
+ every { serviceRepository.setConnectionState(any()) } calls
+ { call ->
+ val state = call.arg(0)
+ observed.add(state)
+ connectionStateFlow.value = state
+ }
+
+ manager = createManager(backgroundScope)
+ advanceUntilIdle()
+
+ // Rapid-fire: Connected -> DeviceSleep -> Disconnected without yielding between them.
+ // Without the Mutex, the intermediate DeviceSleep could be missed or applied out of order.
+ radioConnectionState.value = ConnectionState.Connected
+ radioConnectionState.value = ConnectionState.DeviceSleep
+ radioConnectionState.value = ConnectionState.Disconnected
+ advanceUntilIdle()
+
+ // Verify final state
+ assertEquals(
+ ConnectionState.Disconnected,
+ serviceRepository.connectionState.value,
+ "Final state should be Disconnected after rapid transitions",
+ )
+
+ // Verify that all intermediate states were observed in correct order.
+ // Connected triggers handleConnected() which sets Connecting (handshake start),
+ // then DeviceSleep, then Disconnected.
+ assertEquals(
+ listOf(ConnectionState.Connecting, ConnectionState.DeviceSleep, ConnectionState.Disconnected),
+ observed,
+ "State transitions should be serialized in order: Connecting -> DeviceSleep -> Disconnected",
+ )
+ }
+
+ @Test
+ fun `concurrent sleep-timeout and radio state change are serialized`() {
+ val standardDispatcher = StandardTestDispatcher()
+ runTest(standardDispatcher) {
+ // Power saving enabled with a short ls_secs so the sleep timeout fires quickly
+ val config = LocalConfig(power = Config.PowerConfig(is_power_saving = true, ls_secs = 1))
+ every { radioConfigRepository.localConfigFlow } returns flowOf(config)
+ every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
+
+ val observed = mutableListOf()
+ every { serviceRepository.setConnectionState(any()) } calls
+ { call ->
+ val state = call.arg(0)
+ observed.add(state)
+ connectionStateFlow.value = state
+ }
+
+ manager = createManager(backgroundScope)
+ advanceUntilIdle()
+
+ // Transition to Connected -> DeviceSleep to start the sleep timer
+ radioConnectionState.value = ConnectionState.Connected
+ advanceUntilIdle()
+ radioConnectionState.value = ConnectionState.DeviceSleep
+ advanceUntilIdle()
+
+ observed.clear()
+
+ // Before the sleep timeout fires, emit Connected from the radio (simulating device
+ // waking up). Then let the timeout fire. The mutex ensures they don't race.
+ radioConnectionState.value = ConnectionState.Connected
+ // Advance past the sleep timeout (ls_secs=1 + 30s base = 31s)
+ advanceTimeBy(32_000L)
+ advanceUntilIdle()
+
+ // The Connected transition should have cancelled the sleep timeout, so we should
+ // end up in Connecting (from handleConnected), NOT Disconnected (from timeout).
+ assertEquals(
+ ConnectionState.Connecting,
+ serviceRepository.connectionState.value,
+ "Connected should cancel the sleep timeout; final state should be Connecting",
+ )
+ }
+ }
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
index 5f738b439..022608be1 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerTest.kt
@@ -108,8 +108,8 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
+ scope = testScope,
)
- handler.start(testScope)
// Default: mapper returns null for empty packets, which is the safe default
every { dataMapper.toDataPacket(any()) } returns null
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
index 3090cf49e..251aefabe 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImplTest.kt
@@ -23,6 +23,7 @@ import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verifySuspend
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@@ -65,22 +66,22 @@ class MeshMessageProcessorImplTest {
every { nodeManager.isNodeDbReady } returns isNodeDbReady
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
every { router.dataHandler } returns dataHandler
-
- processor =
- MeshMessageProcessorImpl(
- nodeManager = nodeManager,
- serviceRepository = serviceRepository,
- meshLogRepository = lazy { meshLogRepository },
- router = lazy { router },
- fromRadioDispatcher = fromRadioDispatcher,
- )
}
+ private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl(
+ nodeManager = nodeManager,
+ serviceRepository = serviceRepository,
+ meshLogRepository = lazy { meshLogRepository },
+ router = lazy { router },
+ fromRadioDispatcher = fromRadioDispatcher,
+ scope = scope,
+ )
+
// ---------- handleFromRadio: non-packet variants ----------
@Test
fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
val logRecord = LogRecord(message = "test log")
val fromRadio = FromRadio(log_record = logRecord)
val bytes = FromRadio.ADAPTER.encode(fromRadio)
@@ -93,7 +94,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
// Encode a raw LogRecord (not wrapped in FromRadio) — first decode as FromRadio fails,
// fallback decode as LogRecord succeeds
val logRecord = LogRecord(message = "fallback log")
@@ -108,7 +109,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
// Invalid protobuf bytes — both parses should fail
val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())
@@ -121,7 +122,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -141,7 +142,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -165,7 +166,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = false
// The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test,
@@ -195,7 +196,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -214,7 +215,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -235,7 +236,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -255,7 +256,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val senderNode = 999
@@ -279,7 +280,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packet with null decoded is skipped`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val packet = MeshPacket(id = 1, from = 999, decoded = null)
@@ -293,7 +294,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -315,7 +316,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -342,7 +343,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) {
- processor.start(backgroundScope)
+ processor = createProcessor(backgroundScope)
val logRecord = LogRecord(message = "device log")
val fromRadio = FromRadio(log_record = logRecord)
val bytes = FromRadio.ADAPTER.encode(fromRadio)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
index 022590467..509066867 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
+import kotlinx.coroutines.test.TestScope
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
@@ -44,12 +45,13 @@ class NodeManagerImplTest {
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
private val serviceBroadcasts: ServiceBroadcasts = mock(MockMode.autofill)
private val notificationManager: NotificationManager = mock(MockMode.autofill)
+ private val testScope = TestScope()
private lateinit var nodeManager: NodeManagerImpl
@BeforeTest
fun setUp() {
- nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
+ nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager, testScope)
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
index fe89063ef..e0bda6075 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/PacketHandlerImplTest.kt
@@ -21,6 +21,7 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
+import dev.mokkery.verify
import dev.mokkery.verifySuspend
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
@@ -70,8 +71,8 @@ class PacketHandlerImplTest {
radioInterfaceService,
lazy { meshLogRepository },
serviceRepository,
+ testScope,
)
- handler.start(testScope)
}
@Test
@@ -84,6 +85,8 @@ class PacketHandlerImplTest {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(toRadio)
+
+ verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
@@ -93,6 +96,8 @@ class PacketHandlerImplTest {
handler.sendToRadio(packet)
testScheduler.runCurrent()
+
+ verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
index e465aaa63..900245332 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/StoreForwardPacketHandlerImplTest.kt
@@ -72,8 +72,8 @@ class StoreForwardPacketHandlerImplTest {
serviceBroadcasts = serviceBroadcasts,
historyManager = historyManager,
dataHandler = lazy { dataHandler },
+ scope = testScope,
)
- handler.start(testScope)
}
private fun makeSfPacket(from: Int, sf: StoreAndForward): MeshPacket {
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
index 8f295a2b6..28bf22fdc 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImplTest.kt
@@ -62,8 +62,8 @@ class TelemetryPacketHandlerImplTest {
nodeManager = nodeManager,
connectionManager = lazy { connectionManager },
notificationManager = notificationManager,
+ scope = testScope,
)
- handler.start(testScope)
}
private fun makeTelemetryPacket(from: Int, telemetry: Telemetry): MeshPacket {
diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
new file mode 100644
index 000000000..c26991ac4
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
@@ -0,0 +1,1052 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 38,
+ "identityHash": "ffca7655fa7c1d69fdd404b1b39d140c",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ffca7655fa7c1d69fdd404b1b39d140c')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
index 8062afa76..451a62174 100644
--- a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
+++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/MigrationTest.kt
@@ -20,7 +20,7 @@ import androidx.room3.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
@@ -59,7 +59,7 @@ class MigrationTest {
)
@Before
- fun createDb(): Unit = runBlocking {
+ fun createDb(): Unit = runTest {
val context = ApplicationProvider.getApplicationContext()
database =
Room.inMemoryDatabaseBuilder(
@@ -77,7 +77,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
+ fun testMigrateChannelsByPSK_duplicatePSK() = runTest {
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
@@ -103,7 +103,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_reorder() = runBlocking {
+ fun testMigrateChannelsByPSK_reorder() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
@@ -122,7 +122,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
+ fun testMigrateChannelsByPSK_disambiguateByName() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
@@ -141,7 +141,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
+ fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A")
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
index c917ee066..b2c89ad73 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.database
import okio.ByteString.Companion.encodeUtf8
+import org.meshtastic.core.common.util.normalizeAddress
object DatabaseConstants {
const val DB_PREFIX: String = "meshtastic_database"
@@ -40,17 +41,6 @@ object DatabaseConstants {
const val ADDRESS_ANON_EDGE_LEN: Int = 2
}
-fun normalizeAddress(addr: String?): String {
- val u = addr?.trim()?.uppercase()
- val normalized =
- when {
- u.isNullOrBlank() -> "DEFAULT"
- u == "N" || u == "NULL" -> "DEFAULT"
- else -> u.replace(":", "")
- }
- return normalized
-}
-
fun shortSha1(s: String): String = s.encodeUtf8().sha1().hex().take(DatabaseConstants.DB_NAME_HASH_LEN)
fun buildDbName(address: String?): String = if (address.isNullOrBlank()) {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
index ba5887f95..108345265 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt
@@ -241,6 +241,7 @@ open class DatabaseManager(
victims.forEach { name ->
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(name)
deleteDatabase(name)
datastore.edit { it.remove(lastUsedKey(name)) }
@@ -266,6 +267,7 @@ open class DatabaseManager(
if (fs.exists(legacyPath)) {
runCatching {
+ // runCatching intentional: best-effort cleanup must not abort on cancellation
closeCachedDatabase(legacy)
deleteDatabase(legacy)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 7bf9014ce..13451e5fc 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -94,8 +94,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 34, to = 35, spec = AutoMigration34to35::class),
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
+ AutoMigration(from = 37, to = 38),
],
- version = 37,
+ version = 38,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index fcdc079f2..c1e399c97 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -17,18 +17,15 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.DeviceHardwareEntity
@Dao
interface DeviceHardwareDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(deviceHardware: DeviceHardwareEntity)
+ @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(deviceHardware: List)
+ @Upsert suspend fun insertAll(deviceHardware: List)
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel")
suspend fun getByHwModel(hwModel: Int): List
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
index 0a5520a07..040941a49 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt
@@ -17,16 +17,14 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
@Dao
interface FirmwareReleaseDao {
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
+ @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
@Query("DELETE FROM firmware_release")
suspend fun deleteAll()
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
index 967a97ec5..35d29c161 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/MeshLogDao.kt
@@ -25,10 +25,10 @@ import org.meshtastic.core.database.entity.MeshLog
@Dao
interface MeshLogDao {
- @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT :maxItem")
fun getAllLogs(maxItem: Int): Flow>
- @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,:maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
fun getAllLogsInReceiveOrder(maxItem: Int): Flow>
/**
@@ -40,7 +40,7 @@ interface MeshLogDao {
"""
SELECT * FROM log
WHERE from_num = :fromNum AND (:portNum = -1 OR port_num = :portNum)
- ORDER BY received_date DESC LIMIT 0,:maxItem
+ ORDER BY received_date DESC LIMIT :maxItem
""",
)
fun getLogsFrom(fromNum: Int, portNum: Int, maxItem: Int): Flow>
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index e11d10f50..407a4d853 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -17,9 +17,7 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
import androidx.room3.MapColumn
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Upsert
@@ -37,6 +35,9 @@ interface NodeInfoDao {
companion object {
const val KEY_SIZE = 32
+
+ /** SQLite has a limit of ~999 bind parameters per query. */
+ const val MAX_BIND_PARAMS = 999
}
/**
@@ -168,8 +169,7 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
+ @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@@ -284,9 +284,15 @@ interface NodeInfoDao {
@Transaction
suspend fun getNodeByNum(num: Int): NodeWithRelations?
+ @Query("SELECT * FROM nodes WHERE num IN (:nodeNums)")
+ suspend fun getNodeEntitiesByNums(nodeNums: List): List
+
@Query("SELECT * FROM nodes WHERE public_key = :publicKey LIMIT 1")
suspend fun findNodeByPublicKey(publicKey: ByteString?): NodeEntity?
+ @Query("SELECT * FROM nodes WHERE public_key IN (:publicKeys)")
+ suspend fun findNodesByPublicKeys(publicKeys: List): List
+
@Upsert suspend fun doUpsert(node: NodeEntity)
@Transaction
@@ -295,17 +301,82 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun putAll(nodes: List)
+ @Upsert suspend fun putAll(nodes: List)
@Query("UPDATE nodes SET notes = :notes WHERE num = :num")
suspend fun setNodeNotes(num: Int, notes: String)
+ /**
+ * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two
+ * queries instead of N individual queries, then processes each node in memory.
+ */
+ @Suppress("NestedBlockDepth")
+ private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List {
+ // Prepare all incoming nodes (populate denormalized fields)
+ incomingNodes.forEach { node ->
+ node.publicKey = node.user.public_key
+ if (node.user.hw_model != HardwareModel.UNSET) {
+ node.longName = node.user.long_name
+ node.shortName = node.user.short_name
+ } else {
+ node.longName = null
+ node.shortName = null
+ }
+ }
+
+ // Batch fetch all existing nodes by num (chunked for SQLite bind-param limit)
+ val existingNodesMap =
+ incomingNodes
+ .map { it.num }
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { getNodeEntitiesByNums(it) }
+ .associateBy { it.num }
+
+ // Partition into updates vs. inserts and resolve existing nodes in-memory
+ val result = mutableListOf()
+ val newNodes = mutableListOf()
+ for (incoming in incomingNodes) {
+ val existing = existingNodesMap[incoming.num]
+ if (existing != null) {
+ result.add(handleExistingNodeUpsertValidation(existing, incoming))
+ } else {
+ newNodes.add(incoming)
+ }
+ }
+
+ // Batch validate new nodes' public keys (one query instead of N)
+ val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
+ val pkConflicts =
+ if (publicKeysToCheck.isNotEmpty()) {
+ publicKeysToCheck
+ .chunked(MAX_BIND_PARAMS)
+ .flatMap { findNodesByPublicKeys(it) }
+ .associateBy { it.publicKey }
+ } else {
+ emptyMap()
+ }
+
+ for (newNode in newNodes) {
+ if ((newNode.publicKey?.size ?: 0) > 0) {
+ val conflicting = pkConflicts[newNode.publicKey]
+ if (conflicting != null && conflicting.num != newNode.num) {
+ result.add(conflicting)
+ } else {
+ result.add(newNode)
+ }
+ } else {
+ result.add(newNode)
+ }
+ }
+
+ return result
+ }
+
@Transaction
suspend fun installConfig(mi: MyNodeEntity, nodes: List) {
clearMyNodeInfo()
setMyNodeInfo(mi)
- putAll(nodes.map { getVerifiedNodeForUpsert(it) })
+ putAll(getVerifiedNodesForUpsert(nodes))
}
/**
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
index 1419d51e7..c2ef9c516 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/PacketDao.kt
@@ -18,7 +18,9 @@ package org.meshtastic.core.database.dao
import androidx.paging.PagingSource
import androidx.room3.Dao
+import androidx.room3.Insert
import androidx.room3.MapColumn
+import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
@@ -307,6 +309,16 @@ interface PacketDao {
)
suspend fun getPacketByPacketId(packetId: Int): PacketEntity?
+ @Transaction
+ @Query(
+ """
+ SELECT * FROM packet
+ WHERE packet_id IN (:packetIds)
+ AND (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ """,
+ )
+ suspend fun getPacketsByPacketIds(packetIds: List): List
+
@Query(
"""
SELECT * FROM packet
@@ -326,8 +338,15 @@ interface PacketDao {
)
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
- @Transaction
- suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
+ @Query(
+ """
+ SELECT data FROM packet
+ WHERE (myNodeNum = 0 OR myNodeNum = (SELECT myNodeNum FROM my_node))
+ AND json_extract(data, '${"$"}.status') = 'QUEUED'
+ ORDER BY received_time ASC
+ """,
+ )
+ suspend fun getQueuedPackets(): List
@Query(
"""
@@ -359,23 +378,24 @@ interface PacketDao {
@Upsert suspend fun upsertContactSettings(contacts: List)
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertContactSettingsIgnore(contacts: List)
+
+ @Query("UPDATE contact_settings SET muteUntil = :muteUntil WHERE contact_key IN (:contactKeys)")
+ suspend fun updateMuteUntil(contactKeys: List, muteUntil: Long)
+
@Transaction
suspend fun setMuteUntil(contacts: List, until: Long) {
- val contactList = contacts.map { contact ->
- // Always mute
- val absoluteMuteUntil =
- if (until == Long.MAX_VALUE) {
- Long.MAX_VALUE
- } else if (until == 0L) { // unmute
- 0L
- } else {
- nowMillis + until
- }
-
- getContactSettings(contact)?.copy(muteUntil = absoluteMuteUntil)
- ?: ContactSettings(contact_key = contact, muteUntil = absoluteMuteUntil)
- }
- upsertContactSettings(contactList)
+ val absoluteMuteUntil =
+ when {
+ until == Long.MAX_VALUE -> Long.MAX_VALUE
+ until == 0L -> 0L
+ else -> nowMillis + until
+ }
+ // Ensure rows exist for all contacts (IGNORE avoids overwriting existing data)
+ insertContactSettingsIgnore(contacts.map { ContactSettings(contact_key = it) })
+ // Atomic column-level update — no read-then-write race
+ updateMuteUntil(contacts, absoluteMuteUntil)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@@ -479,9 +499,10 @@ interface PacketDao {
val indexMap =
oldSettings
.mapIndexed { oldIndex, oldChannel ->
- val pskMatches = newSettings.mapIndexedNotNull { index, channel ->
- if (channel.psk == oldChannel.psk) index to channel else null
- }
+ val pskMatches =
+ newSettings.mapIndexedNotNull { index, channel ->
+ if (channel.psk == oldChannel.psk) index to channel else null
+ }
val newIndex =
when {
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
index 2e7f6c549..fde388ce5 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/TracerouteNodePositionDao.kt
@@ -17,9 +17,8 @@
package org.meshtastic.core.database.dao
import androidx.room3.Dao
-import androidx.room3.Insert
-import androidx.room3.OnConflictStrategy
import androidx.room3.Query
+import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
@@ -32,6 +31,5 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
- @Insert(onConflict = OnConflictStrategy.REPLACE)
- suspend fun insertAll(entities: List)
+ @Upsert suspend fun insertAll(entities: List)
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
index 13d10193c..fed88eef9 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
@@ -118,6 +118,7 @@ data class MetadataEntity(
Index(value = ["hops_away"]),
Index(value = ["is_favorite"]),
Index(value = ["last_heard", "is_favorite"]),
+ Index(value = ["public_key"]),
],
)
data class NodeEntity(
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
index 16b1e66e4..d01171751 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt
@@ -74,6 +74,9 @@ data class PacketEntity(
Index(value = ["contact_key"]),
Index(value = ["contact_key", "port_num", "received_time"]),
Index(value = ["packet_id"]),
+ Index(value = ["received_time"]),
+ Index(value = ["filtered"]),
+ Index(value = ["read"]),
],
)
data class Packet(
@@ -98,9 +101,12 @@ data class Packet(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
index 6da9df5b7..71a7fef1c 100644
--- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
+++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonPacketDaoTest.kt
@@ -271,6 +271,42 @@ abstract class CommonPacketDaoTest {
assertFalse(excludingFiltered.any { it.packet.filtered })
}
+ @Test
+ fun testGetPacketsByPacketIdsChunked() = runTest {
+ // Regression test for SQLITE_MAX_VARIABLE_NUMBER (999) limit. Inserting >999 packets and
+ // looking them up by id must not throw; callers are expected to chunk, and each chunk
+ // must return the correct rows.
+ val totalPackets = 2000
+ val chunkSize = NodeInfoDao.MAX_BIND_PARAMS
+ val contactKey = "chunk-test"
+ val baseTime = nowMillis
+ val packetIds = (1..totalPackets).toList()
+
+ packetIds.forEach { id ->
+ packetDao.insert(
+ Packet(
+ uuid = 0L,
+ myNodeNum = myNodeNum,
+ port_num = PortNum.TEXT_MESSAGE_APP.value,
+ contact_key = contactKey,
+ received_time = baseTime + id,
+ read = false,
+ data =
+ DataPacket(
+ to = DataPacket.ID_BROADCAST,
+ bytes = "Chunk $id".encodeToByteArray().toByteString(),
+ dataType = PortNum.TEXT_MESSAGE_APP.value,
+ ),
+ packetId = id,
+ ),
+ )
+ }
+
+ val fetched = packetIds.chunked(chunkSize).flatMap { packetDao.getPacketsByPacketIds(it) }
+ assertEquals(totalPackets, fetched.size)
+ assertEquals(packetIds.toSet(), fetched.map { it.packet.packetId }.toSet())
+ }
+
companion object {
private const val SAMPLE_SIZE = 10
}
diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts
index 903dde119..7d46cc831 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -24,7 +24,11 @@ plugins {
kotlin {
jvm()
- android { namespace = "org.meshtastic.core.datastore" }
+ android {
+ namespace = "org.meshtastic.core.datastore"
+ androidResources.enable = false
+ withHostTest {}
+ }
sourceSets {
commonMain.dependencies {
@@ -36,5 +40,11 @@ kotlin {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
}
+
+ commonTest.dependencies {
+ implementation(kotlin("test"))
+ implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.okio)
+ }
}
}
diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
index 94ef1c605..9de792a84 100644
--- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
+++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt
@@ -50,7 +50,7 @@ class PreferencesDataStoreModule {
@Named("CorePreferencesDataStore")
fun providePreferencesDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
- @Named("DataStoreScope") scope: CoroutineScope,
+ @Named(DATASTORE_SCOPE) scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
index aa81f1ac6..3cb3cabe8 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -24,10 +24,17 @@ import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
+/**
+ * Koin qualifier for the application-scoped [CoroutineScope] shared by all [DataStore] instances.
+ *
+ * Used with `@Named(DATASTORE_SCOPE)` in Koin annotations and `named(DATASTORE_SCOPE)` in manual DSL modules.
+ */
+const val DATASTORE_SCOPE = "DataStoreScope"
+
@Module
@ComponentScan("org.meshtastic.core.datastore")
class CoreDatastoreModule {
@Single
- @Named("DataStoreScope")
+ @Named(DATASTORE_SCOPE)
fun provideDataStoreScope(): CoroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
}
diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
new file mode 100644
index 000000000..3acd29cb9
--- /dev/null
+++ b/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
@@ -0,0 +1,286 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.datastore
+
+import androidx.datastore.preferences.core.PreferenceDataStoreFactory
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.contentOrNull
+import kotlinx.serialization.json.jsonArray
+import kotlinx.serialization.json.jsonPrimitive
+import okio.FileSystem
+import okio.Path
+import org.meshtastic.core.datastore.model.RecentAddress
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.uuid.ExperimentalUuidApi
+import kotlin.uuid.Uuid
+
+@OptIn(ExperimentalUuidApi::class)
+class RecentAddressesDataSourceTest {
+ private lateinit var tmpDir: Path
+ private lateinit var dataSource: RecentAddressesDataSource
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+ private val testScope = TestScope(testDispatcher)
+
+ @BeforeTest
+ fun setup() {
+ tmpDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "recentAddressesTest-${Uuid.random()}"
+ FileSystem.SYSTEM.createDirectories(tmpDir)
+ val dataStore =
+ PreferenceDataStoreFactory.createWithPath(
+ scope = testScope,
+ produceFile = { tmpDir / "test.preferences_pb" },
+ )
+ dataSource = RecentAddressesDataSource(dataStore)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ FileSystem.SYSTEM.deleteRecursively(tmpDir)
+ }
+
+ // ---- recentAddresses flow ----
+
+ @Test
+ fun `recentAddresses emits empty list when no data stored`() = testScope.runTest {
+ val result = dataSource.recentAddresses.first()
+ assertTrue(result.isEmpty())
+ }
+
+ @Test
+ fun `setRecentAddresses persists and emits the list`() = testScope.runTest {
+ val addresses =
+ listOf(
+ RecentAddress(address = "192.168.1.1", name = "Home"),
+ RecentAddress(address = "10.0.0.1", name = "Office"),
+ )
+ dataSource.setRecentAddresses(addresses)
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(addresses, result)
+ }
+
+ @Test
+ fun `setRecentAddresses overwrites previous value`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.2.3.4", "Old")))
+ dataSource.setRecentAddresses(listOf(RecentAddress("5.6.7.8", "New")))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("5.6.7.8", result[0].address)
+ }
+
+ // ---- add() LRU behaviour ----
+
+ @Test
+ fun `add to empty list stores single entry`() = testScope.runTest {
+ dataSource.add(RecentAddress("192.168.0.1", "Router"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("192.168.0.1", result[0].address)
+ }
+
+ @Test
+ fun `add prepends new address to front`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "Existing")))
+ dataSource.add(RecentAddress("2.2.2.2", "New"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals("2.2.2.2", result[0].address)
+ assertEquals("1.1.1.1", result[1].address)
+ }
+
+ @Test
+ fun `add deduplicates by address moving existing entry to front with updated name`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "First"), RecentAddress("2.2.2.2", "Second")))
+ dataSource.add(RecentAddress("2.2.2.2", "Second-updated"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(2, result.size)
+ assertEquals("2.2.2.2", result[0].address)
+ assertEquals("Second-updated", result[0].name)
+ assertEquals("1.1.1.1", result[1].address)
+ }
+
+ @Test
+ fun `add enforces CACHE_CAPACITY of 3 evicting oldest entry`() = testScope.runTest {
+ dataSource.setRecentAddresses(
+ listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")),
+ )
+ dataSource.add(RecentAddress("4.4.4.4", "D"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(3, result.size)
+ assertEquals("4.4.4.4", result[0].address)
+ assertEquals("1.1.1.1", result[1].address)
+ assertEquals("2.2.2.2", result[2].address)
+ assertFalse(result.any { it.address == "3.3.3.3" })
+ }
+
+ @Test
+ fun `add re-adding the same address at front keeps capacity`() = testScope.runTest {
+ dataSource.setRecentAddresses(
+ listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B"), RecentAddress("3.3.3.3", "C")),
+ )
+ dataSource.add(RecentAddress("1.1.1.1", "A"))
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(3, result.size)
+ assertEquals("1.1.1.1", result[0].address)
+ }
+
+ // ---- remove() ----
+
+ @Test
+ fun `remove deletes the matching address`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A"), RecentAddress("2.2.2.2", "B")))
+ dataSource.remove("1.1.1.1")
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ assertEquals("2.2.2.2", result[0].address)
+ }
+
+ @Test
+ fun `remove on unknown address is a no-op`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A")))
+ dataSource.remove("9.9.9.9")
+
+ val result = dataSource.recentAddresses.first()
+ assertEquals(1, result.size)
+ }
+
+ @Test
+ fun `remove last address yields empty list`() = testScope.runTest {
+ dataSource.setRecentAddresses(listOf(RecentAddress("1.1.1.1", "A")))
+ dataSource.remove("1.1.1.1")
+
+ assertTrue(dataSource.recentAddresses.first().isEmpty())
+ }
+
+ // ---- legacy JSON parsing (via LegacyParsingHarness) ----
+
+ @Test
+ fun `legacy JsonObject array is parsed correctly`() = testScope.runTest {
+ val legacyJson =
+ """[{"address":"192.168.1.100","name":"NodeA"},{"address":"192.168.1.101","name":"NodeB"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("192.168.1.100", result[0].address)
+ assertEquals("NodeA", result[0].name)
+ assertEquals("192.168.1.101", result[1].address)
+ assertEquals("NodeB", result[1].name)
+ }
+
+ @Test
+ fun `legacy bare string JsonPrimitive array is parsed correctly`() = testScope.runTest {
+ // Old clients stored plain IP strings with no name field
+ val legacyJson = """["192.168.1.50","10.0.0.2"]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("192.168.1.50", result[0].address)
+ assertEquals("Meshtastic", result[0].name)
+ assertEquals("10.0.0.2", result[1].address)
+ assertEquals("Meshtastic", result[1].name)
+ }
+
+ @Test
+ fun `legacy JsonObject missing address field is skipped`() = testScope.runTest {
+ val legacyJson = """[{"name":"NoAddress"},{"address":"1.2.3.4","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("1.2.3.4", result[0].address)
+ }
+
+ @Test
+ fun `legacy JsonObject missing name field is skipped`() = testScope.runTest {
+ val legacyJson = """[{"address":"1.2.3.4"},{"address":"5.6.7.8","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("5.6.7.8", result[0].address)
+ }
+
+ @Test
+ fun `legacy nested JsonArray entries are skipped`() = testScope.runTest {
+ val legacyJson = """[["nested","array"],{"address":"1.2.3.4","name":"Good"}]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(1, result.size)
+ assertEquals("1.2.3.4", result[0].address)
+ }
+
+ @Test
+ fun `legacy mixed array handles all element types`() = testScope.runTest {
+ // JsonPrimitive + valid JsonObject + malformed JsonObject + nested JsonArray
+ val legacyJson = """["10.0.0.1",{"address":"10.0.0.2","name":"Node"},{"name":"bad"},[1,2]]"""
+ val result = LegacyParsingHarness(legacyJson).recentAddresses.first()
+
+ assertEquals(2, result.size)
+ assertEquals("10.0.0.1", result[0].address)
+ assertEquals("Meshtastic", result[0].name)
+ assertEquals("10.0.0.2", result[1].address)
+ }
+}
+
+/**
+ * Test harness that mirrors the private legacy parsing logic of [RecentAddressesDataSource] without needing to bypass
+ * encapsulation. Exposes a [Flow] that emits the result of parsing a raw legacy JSON string using the same rules as the
+ * production fallback path.
+ */
+private class LegacyParsingHarness(private val rawJson: String) {
+ val recentAddresses: Flow> = flow {
+ val jsonArray = Json.parseToJsonElement(rawJson).jsonArray
+ emit(
+ jsonArray.mapNotNull { item ->
+ when (item) {
+ is JsonObject -> {
+ val address = item["address"]?.jsonPrimitive?.contentOrNull
+ val name = item["name"]?.jsonPrimitive?.contentOrNull
+ if (address != null && name != null) {
+ RecentAddress(address = address, name = name)
+ } else {
+ null
+ }
+ }
+ is JsonPrimitive -> {
+ item.contentOrNull?.let { RecentAddress(address = it, name = "Meshtastic") }
+ }
+ is JsonArray -> null
+ }
+ },
+ )
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
similarity index 74%
rename from core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt
rename to core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
index 5d9991e34..fa708d165 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/NopInterfaceFactory.kt
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/SetContrastLevelUseCase.kt
@@ -14,12 +14,14 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.network.radio
+package org.meshtastic.core.domain.usecase.settings
import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.UiPrefs
-/** Factory for creating `NopInterface` instances. */
@Single
-class NopInterfaceFactory {
- fun create(rest: String): NopInterface = NopInterface(rest)
+open class SetContrastLevelUseCase constructor(private val uiPrefs: UiPrefs) {
+ operator fun invoke(value: Int) {
+ uiPrefs.setContrastLevel(value)
+ }
}
diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts
index 4e01fc223..92374706a 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -58,6 +58,8 @@ kotlin {
implementation(libs.androidx.test.runner)
}
}
+
+ commonTest.dependencies { implementation(projects.core.testing) }
}
}
diff --git a/core/model/consumer-rules.pro b/core/model/consumer-rules.pro
deleted file mode 100644
index 5f75d687d..000000000
--- a/core/model/consumer-rules.pro
+++ /dev/null
@@ -1,2 +0,0 @@
--keep class org.meshtastic.core.model.DataPacket
--keep class org.meshtastic.core.model.DataPacket$CREATOR
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
deleted file mode 100644
index 473e482e2..000000000
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
+++ /dev/null
@@ -1,51 +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.core.model.util
-
-import org.meshtastic.core.common.util.nowInstant
-import org.meshtastic.core.common.util.toDate
-import org.meshtastic.core.common.util.toInstant
-import java.text.DateFormat
-import kotlin.time.Duration.Companion.hours
-
-private val DAY_DURATION = 24.hours
-
-/**
- * Returns a short string representing the time if it's within the last 24 hours, otherwise returns a short string
- * representing the date.
- *
- * @param time The time in milliseconds
- * @return Formatted date or time string, or null if time is 0
- */
-fun getShortDate(time: Long): String? {
- if (time == 0L) return null
- val instant = time.toInstant()
- val isWithin24Hours = (nowInstant - instant) <= DAY_DURATION
-
- return if (isWithin24Hours) {
- DateFormat.getTimeInstance(DateFormat.SHORT).format(instant.toDate())
- } else {
- DateFormat.getDateInstance(DateFormat.SHORT).format(instant.toDate())
- }
-}
-
-/**
- * Calculates the remaining mute time in days and hours.
- *
- * @param remainingMillis The remaining time in milliseconds
- * @return Pair of (days, hours), where days is Int and hours is Double
- */
diff --git a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
index 13b0789de..99debb5ab 100644
--- a/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/UriBridge.kt
@@ -17,12 +17,13 @@
package org.meshtastic.core.model.util
import android.net.Uri
+import com.eygraber.uri.toKmpUri
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.SharedContact
/** Extension to bridge android.net.Uri to CommonUri for shared dispatch logic. */
-fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
+fun Uri.toCommonUri(): CommonUri = this.toKmpUri()
/** Bridge extension for Android clients. */
fun Uri.dispatchMeshtasticUri(
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
index 65096604f..4e02ae2a7 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt
@@ -34,7 +34,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Ability to mute notifications from specific nodes via admin messages. */
val canMuteNode = atLeast(V2_7_18)
- /** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
+ /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */
val canRequestNeighborInfo = atLeast(UNRELEASED)
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
@@ -49,8 +49,8 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing = atLeast(V2_6_8)
- /** Support for Status Message module. Supported since firmware v2.7.17. */
- val supportsStatusMessage = atLeast(V2_7_17)
+ /** Support for Status Message module. Supported since firmware v2.8.0. */
+ val supportsStatusMessage = atLeast(V2_8_0)
/** Support for Traffic Management module. Supported since firmware v3.0.0. */
val supportsTrafficManagementConfig = atLeast(V3_0_0)
@@ -69,9 +69,9 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
private val V2_6_9 = DeviceVersion("2.6.9")
private val V2_6_10 = DeviceVersion("2.6.10")
private val V2_7_12 = DeviceVersion("2.7.12")
- private val V2_7_17 = DeviceVersion("2.7.17")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_7_19 = DeviceVersion("2.7.19")
+ private val V2_8_0 = DeviceVersion("2.8.0")
private val V3_0_0 = DeviceVersion("3.0.0")
private val UNRELEASED = DeviceVersion("9.9.9")
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
index 0af5a0efd..c8bbdadb5 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ConnectionState.kt
@@ -16,24 +16,16 @@
*/
package org.meshtastic.core.model
-sealed class ConnectionState {
+sealed interface ConnectionState {
/** We are disconnected from the device, and we should be trying to reconnect. */
- data object Disconnected : ConnectionState()
+ data object Disconnected : ConnectionState
/** We are currently attempting to connect to the device. */
- data object Connecting : ConnectionState()
+ data object Connecting : ConnectionState
/** We are connected to the device and communicating normally. */
- data object Connected : ConnectionState()
+ data object Connected : ConnectionState
/** The device is in a light sleep state, and we are waiting for it to wake up and reconnect to us. */
- data object DeviceSleep : ConnectionState()
-
- fun isConnected() = this == Connected
-
- fun isConnecting() = this == Connecting
-
- fun isDisconnected() = this == Disconnected
-
- fun isDeviceSleep() = this == DeviceSleep
+ data object DeviceSleep : ConnectionState
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
new file mode 100644
index 000000000..4d3bfca10
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+/**
+ * App-level MQTT proxy connection state, decoupled from the MQTT library's internal type.
+ *
+ * Modeled as a sealed class so disconnect / reconnect events can carry diagnostic context — the user-facing reason for
+ * an unexpected disconnect, or the most recent reconnect attempt failure — without requiring downstream consumers to
+ * depend on the MQTT library's exception types.
+ */
+sealed class MqttConnectionState {
+ /** The MQTT proxy has not been started (disabled or not yet initialized). */
+ data object Inactive : MqttConnectionState()
+
+ /** The MQTT client is actively connecting to the broker. */
+ data object Connecting : MqttConnectionState()
+
+ /** The MQTT client is connected and subscribed to topics. */
+ data object Connected : MqttConnectionState()
+
+ /**
+ * The MQTT client lost connection and is attempting to reconnect.
+ *
+ * @property attempt 1-based attempt counter for the current reconnect loop.
+ * @property lastError Localized message from the most recent reconnect failure, if any.
+ */
+ data class Reconnecting(val attempt: Int = 0, val lastError: String? = null) : MqttConnectionState()
+
+ /**
+ * The MQTT client is not connected to the broker.
+ *
+ * @property reason Localized failure message for an unexpected disconnect, or `null` for the idle / initial /
+ * intentional-close case (use [Idle]).
+ */
+ data class Disconnected(val reason: String? = null) : MqttConnectionState() {
+ companion object {
+ /** Singleton for the idle / no-reason disconnected state. */
+ val Idle: Disconnected = Disconnected(reason = null)
+ }
+ }
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt
new file mode 100644
index 000000000..e3cb7c77a
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt
@@ -0,0 +1,52 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model
+
+/**
+ * UI-friendly outcome of a one-shot MQTT broker reachability probe.
+ *
+ * Mirrors the failure shapes of `org.meshtastic.mqtt.ProbeResult` but stays in the model module so feature/UI code can
+ * consume the result without depending on the MQTT library.
+ */
+sealed class MqttProbeStatus {
+ /** Probe is currently in flight. */
+ data object Probing : MqttProbeStatus()
+
+ /**
+ * Broker accepted the connection. [serverInfo] is a short human-readable summary of any CONNACK properties that are
+ * useful to surface to the user.
+ */
+ data class Success(val serverInfo: String?) : MqttProbeStatus()
+
+ /** Broker rejected the connection (CONNACK with non-zero reason code). */
+ data class Rejected(val reasonCode: Int, val reason: String?, val serverReference: String?) : MqttProbeStatus()
+
+ /** DNS lookup failed. */
+ data class DnsFailure(val message: String?) : MqttProbeStatus()
+
+ /** TCP socket could not be opened. */
+ data class TcpFailure(val message: String?) : MqttProbeStatus()
+
+ /** TLS handshake failed. */
+ data class TlsFailure(val message: String?) : MqttProbeStatus()
+
+ /** Probe exceeded its timeout. */
+ data class Timeout(val timeoutMs: Long) : MqttProbeStatus()
+
+ /** Any other / unclassified failure. */
+ data class Other(val message: String?) : MqttProbeStatus()
+}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index 13eccae2a..70dea8574 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -19,10 +19,9 @@ package org.meshtastic.core.model
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.common.util.GPSFormat
+import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.bearing
-import org.meshtastic.core.common.util.formatString
import org.meshtastic.core.common.util.latLongToMeter
-import org.meshtastic.core.model.util.UnitConversions.celsiusToFahrenheit
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.proto.Config
@@ -143,34 +142,26 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List {
val temp =
if ((temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
- } else {
- formatString("%.1f°C", temperature)
- }
+ MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
} else {
null
}
- val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
+ val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
- if (isFahrenheit) {
- formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
- } else {
- formatString("%.1f°C", soil_temperature)
- }
+ MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
- formatString("%d%%", soil_moisture)
+ MetricFormatter.percent(soil_moisture ?: 0)
} else {
null
}
- val voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
- val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
+ val voltage = if ((this.voltage ?: 0f) != 0f) MetricFormatter.voltage(this.voltage ?: 0f) else null
+ val current = if ((current ?: 0f) != 0f) MetricFormatter.current(current ?: 0f) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
@@ -199,9 +190,12 @@ data class Node(
fun getRelayNode(relayNodeId: Int, nodes: List, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
- val candidateRelayNodes = nodes.filter {
- it.num != ourNodeNum && it.lastHeard != 0 && (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
- }
+ val candidateRelayNodes =
+ nodes.filter {
+ it.num != ourNodeNum &&
+ it.lastHeard != 0 &&
+ (it.num and RELAY_NODE_SUFFIX_MASK) == relayNodeIdSuffix
+ }
val closestRelayNode =
if (candidateRelayNodes.size == 1) {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
index 54797eb75..84994e628 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/RadioController.kt
@@ -28,7 +28,16 @@ import org.meshtastic.proto.ClientNotification
*/
@Suppress("TooManyFunctions")
interface RadioController {
- /** Reactive connection state of the radio. */
+ /**
+ * Canonical app-level connection state, delegated from [ServiceRepository][connectionState].
+ *
+ * This exposes the same single source of truth as `ServiceRepository.connectionState`, surfaced through the
+ * controller interface for convenience in feature modules and ViewModels that depend on [RadioController] rather
+ * than [ServiceRepository] directly.
+ *
+ * This is **not** the transport-level state — it reflects the fully reconciled app-level state including handshake
+ * progress and device sleep policy.
+ */
val connectionState: StateFlow
/**
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
index 6f27bb0e6..dfe70fd92 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt
@@ -18,8 +18,11 @@
package org.meshtastic.core.model.util
+import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
+import org.meshtastic.proto.ModuleConfig
+import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.Telemetry
/**
@@ -32,7 +35,7 @@ val Any?.anonymize: String
get() = this.anonymize()
/** A version of anonymize that allows passing in a custom minimum length */
-fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null"
+fun Any?.anonymize(maxLen: Int = 3) = if (this != null) "...${this.toString().takeLast(maxLen)}" else "null"
// A toString that makes sure all newlines are removed (for nice logging).
fun Any.toOneLineString() = this.toString().replace('\n', ' ')
@@ -48,6 +51,24 @@ fun MeshPacket.toOneLineString(): String {
return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
}
+fun Channel.toOneLineString(): String {
+ // Redact the channel preshared key (psk) from logs.
+ val redactedFields = """(psk)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
+fun ModuleConfig.toOneLineString(): String {
+ // Redact MQTT credentials from logs.
+ val redactedFields = """(password|username)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
+fun MyNodeInfo.toOneLineString(): String {
+ // Redact the hardware unique identifier from logs.
+ val redactedFields = """(device_id)=[^,}]+"""
+ return this.toString().replace(redactedFields.toRegex()) { "${it.groupValues[1]}=[REDACTED]" }.replace('\n', ' ')
+}
+
fun Any.toPIIString() = if (!isDebug) {
""
} else {
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
index ca035a7fd..ebdcc0f5e 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
@@ -16,7 +16,27 @@
*/
package org.meshtastic.core.model.util
+import okio.ByteString.Companion.toByteString
+
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
-expect object SfppHasher {
- fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
+object SfppHasher {
+ private const val HASH_SIZE = 16
+ private const val INT_BYTES = 4
+ private const val INT_COUNT = 3
+ private const val SHIFT_8 = 8
+ private const val SHIFT_16 = 16
+ private const val SHIFT_24 = 24
+
+ fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
+ val input = ByteArray(encryptedPayload.size + INT_BYTES * INT_COUNT)
+ encryptedPayload.copyInto(input)
+ var offset = encryptedPayload.size
+ for (value in intArrayOf(to, from, id)) {
+ input[offset++] = value.toByte()
+ input[offset++] = (value shr SHIFT_8).toByte()
+ input[offset++] = (value shr SHIFT_16).toByte()
+ input[offset++] = (value shr SHIFT_24).toByte()
+ }
+ return input.toByteString().sha256().toByteArray().copyOf(HASH_SIZE)
+ }
}
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
index b2e175382..4b3f5d149 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -107,7 +107,7 @@ fun compareUsers(oldUser: User, newUser: User): String {
return if (changes.isEmpty()) {
"No changes detected."
} else {
- "Changes:\n" + changes.joinToString("\n")
+ "Changes:\n${changes.joinToString("\n")}"
}
}
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
index ecaf88db6..365a47c61 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/CapabilitiesTest.kt
@@ -68,9 +68,9 @@ class CapabilitiesTest {
}
@Test
- fun supportsStatusMessage_requires_V2_7_17() {
- assertFalse(caps("2.7.16").supportsStatusMessage)
- assertTrue(caps("2.7.17").supportsStatusMessage)
+ fun supportsStatusMessage_requires_V2_8_0() {
+ assertFalse(caps("2.7.21").supportsStatusMessage)
+ assertTrue(caps("2.8.0").supportsStatusMessage)
}
@Test
diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
similarity index 95%
rename from core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
rename to core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
index 51f6a5c76..14dfd72c8 100644
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
@@ -14,12 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.core.common
+package org.meshtastic.core.model.util
import kotlin.test.Test
import kotlin.test.assertEquals
-class ByteUtilsTest {
+class CommonUtilsTest {
@Test
fun testByteArrayOfInts() {
diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
new file mode 100644
index 000000000..917414e3d
--- /dev/null
+++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.model.util
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class SfppHasherTest {
+
+ @Test
+ fun outputIsAlways16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(1, 2, 3), to = 100, from = 200, id = 1)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun emptyPayloadProduces16Bytes() {
+ val hash = SfppHasher.computeMessageHash(byteArrayOf(), to = 0, from = 0, id = 0)
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun deterministicOutput() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(0xAB.toByte()), to = 1, from = 2, id = 3)
+ assertEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentPayloadsProduceDifferentHashes() {
+ val a = SfppHasher.computeMessageHash(byteArrayOf(1), to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(byteArrayOf(2), to = 1, from = 2, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentIdsProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 100)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 101)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun differentFromProduceDifferentHashes() {
+ val payload = byteArrayOf(0x10, 0x20)
+ val a = SfppHasher.computeMessageHash(payload, to = 1, from = 2, id = 3)
+ val b = SfppHasher.computeMessageHash(payload, to = 1, from = 99, id = 3)
+ assertNotEquals(a.toList(), b.toList())
+ }
+
+ @Test
+ fun maxIntValues() {
+ val hash =
+ SfppHasher.computeMessageHash(
+ byteArrayOf(0xFF.toByte()),
+ to = Int.MAX_VALUE,
+ from = Int.MAX_VALUE,
+ id = Int.MAX_VALUE,
+ )
+ assertEquals(16, hash.size)
+ }
+
+ @Test
+ fun littleEndianByteOrder() {
+ // Verify the integer 0x04030201 is encoded as [01, 02, 03, 04] (little-endian)
+ val hashA = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x04030201, from = 0, id = 0)
+ val hashB = SfppHasher.computeMessageHash(byteArrayOf(), to = 0x01020304, from = 0, id = 0)
+ // Different byte orderings must produce different hashes
+ assertNotEquals(hashA.toList(), hashB.toList())
+ }
+}
diff --git a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
index 7545a00a7..d17abd4a3 100644
--- a/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
+++ b/core/model/src/iosMain/kotlin/org/meshtastic/core/model/util/NoopStubs.kt
@@ -20,7 +20,3 @@ package org.meshtastic.core.model.util
actual fun getShortDateTime(time: Long): String = ""
actual fun platformRandomBytes(size: Int): ByteArray = ByteArray(size)
-
-actual object SfppHasher {
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray = ByteArray(32)
-}
diff --git a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
deleted file mode 100644
index b1c25110b..000000000
--- a/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
+++ /dev/null
@@ -1,35 +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.core.model.util
-
-import java.nio.ByteBuffer
-import java.nio.ByteOrder
-import java.security.MessageDigest
-
-actual object SfppHasher {
- private const val HASH_SIZE = 16
- private const val INT_BYTES = 4
-
- actual fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray {
- val digest = MessageDigest.getInstance("SHA-256")
- digest.update(encryptedPayload)
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(to).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(from).array())
- digest.update(ByteBuffer.allocate(INT_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(id).array())
- return digest.digest().copyOf(HASH_SIZE)
- }
-}
diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts
index 99a0802ae..858229b69 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -32,5 +32,7 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.kermit)
}
+
+ commonTest.dependencies { implementation(projects.core.testing) }
}
}
diff --git a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt
index c4d3ac044..c36375356 100644
--- a/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt
+++ b/core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/MultiBackstackTest.kt
@@ -111,4 +111,35 @@ class MultiBackstackTest {
assertEquals(2, multiBackstack.activeBackStack.size)
assertEquals(SettingsRoute.About, multiBackstack.activeBackStack.last())
}
+
+ @Test
+ fun `handleDeepLink from different tab switches tab and sets stack`() {
+ // Start on Connections tab
+ val startTab = TopLevelDestination.Connections.route
+ val multiBackstack = MultiBackstack(startTab)
+
+ val connectionsStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Connections.route)) }
+ val nodesStack = NavBackStack().apply { addAll(listOf(TopLevelDestination.Nodes.route)) }
+
+ multiBackstack.backStacks =
+ mapOf(
+ TopLevelDestination.Connections.route to connectionsStack,
+ TopLevelDestination.Nodes.route to nodesStack,
+ )
+
+ // Verify we start on Connections
+ assertEquals(TopLevelDestination.Connections.route, multiBackstack.currentTabRoute)
+
+ // Deep-link to a TracerouteMap on the Nodes tab (this is the exact pattern
+ // MeshtasticAppShell uses for traceroute alert "View on Map")
+ val tracerouteMap = NodeDetailRoute.TracerouteMap(destNum = 100, requestId = 42, logUuid = "abc")
+ multiBackstack.handleDeepLink(listOf(NodesRoute.NodesGraph, tracerouteMap))
+
+ // Should have switched to the Nodes tab
+ assertEquals(TopLevelDestination.Nodes.route, multiBackstack.currentTabRoute)
+ // Stack should contain the graph root + the traceroute map route
+ assertEquals(2, multiBackstack.activeBackStack.size)
+ assertEquals(NodesRoute.NodesGraph, multiBackstack.activeBackStack.first())
+ assertEquals(tracerouteMap, multiBackstack.activeBackStack.last())
+ }
}
diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts
index 1c0d14a01..f2fb85d7f 100644
--- a/core/network/build.gradle.kts
+++ b/core/network/build.gradle.kts
@@ -40,11 +40,11 @@ kotlin {
implementation(projects.core.ble)
implementation(libs.okio)
- implementation(libs.kmqtt.client)
- implementation(libs.kmqtt.common)
+ api(libs.meshtastic.mqtt.client)
implementation(libs.kotlinx.serialization.json)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kermit)
implementation(libs.jetbrains.lifecycle.runtime)
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
index 28eb2175d..426c6700b 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/AndroidRadioTransportFactory.kt
@@ -17,6 +17,7 @@
package org.meshtastic.core.network.radio
import android.content.Context
+import android.hardware.usb.UsbManager
import android.provider.Settings
import org.koin.core.annotation.Single
import org.meshtastic.core.ble.BleConnectionFactory
@@ -25,21 +26,23 @@ import org.meshtastic.core.ble.BluetoothRepository
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceType
+import org.meshtastic.core.model.InterfaceId
+import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioTransport
import org.meshtastic.core.repository.RadioTransportFactory
/**
* Android implementation of [RadioTransportFactory]. Handles pure-KMP transports (BLE) via [BaseRadioTransportFactory]
- * while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific
- * [InterfaceFactory].
+ * while creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport].
*/
@Single(binds = [RadioTransportFactory::class])
@Suppress("LongParameterList")
class AndroidRadioTransportFactory(
private val context: Context,
- private val interfaceFactory: Lazy,
private val buildConfigProvider: BuildConfigProvider,
+ private val usbRepository: UsbRepository,
+ private val usbManager: UsbManager,
scanner: BleScanner,
bluetoothRepository: BluetoothRepository,
connectionFactory: BleConnectionFactory,
@@ -48,13 +51,50 @@ class AndroidRadioTransportFactory(
override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
- override fun isMockInterface(): Boolean =
+ override fun isMockTransport(): Boolean =
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
- override fun isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address)
+ override fun isPlatformAddressValid(address: String): Boolean {
+ val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) } ?: return false
+ val rest = address.substring(1)
+ return when (interfaceId) {
+ InterfaceId.MOCK,
+ InterfaceId.NOP,
+ InterfaceId.TCP,
+ -> true
+ InterfaceId.SERIAL -> {
+ val deviceMap = usbRepository.serialDevices.value
+ val driver = deviceMap[rest] ?: deviceMap.values.firstOrNull()
+ driver != null && usbManager.hasPermission(driver.device)
+ }
+ InterfaceId.BLUETOOTH -> true // Handled by base class
+ }
+ }
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport {
- // Fallback to legacy factory for Serial, Mocks, and NOPs
- return interfaceFactory.value.createInterface(address, service)
+ val interfaceId = address.firstOrNull()?.let { InterfaceId.forIdChar(it) }
+ val rest = address.substring(1)
+
+ return when (interfaceId) {
+ InterfaceId.MOCK -> MockRadioTransport(callback = service, scope = service.serviceScope, address = rest)
+ InterfaceId.TCP ->
+ TcpRadioTransport(
+ callback = service,
+ scope = service.serviceScope,
+ dispatchers = dispatchers,
+ address = rest,
+ )
+ InterfaceId.SERIAL ->
+ SerialRadioTransport(
+ callback = service,
+ scope = service.serviceScope,
+ usbRepository = usbRepository,
+ address = rest,
+ )
+ InterfaceId.NOP,
+ null,
+ -> NopRadioTransport(rest)
+ InterfaceId.BLUETOOTH -> error("BLE addresses should be handled by BaseRadioTransportFactory")
+ }
}
}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt
deleted file mode 100644
index f33cedfae..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt
+++ /dev/null
@@ -1,66 +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.core.network.radio
-
-import org.koin.core.annotation.Single
-import org.meshtastic.core.model.InterfaceId
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-
-/**
- * Entry point for create radio backend instances given a specific address.
- *
- * This class is responsible for building and dissecting radio addresses based upon their interface type and the "rest"
- * of the address (which varies per implementation).
- */
-@Single
-class InterfaceFactory(
- private val nopInterfaceFactory: NopInterfaceFactory,
- private val mockSpec: Lazy,
- private val serialSpec: Lazy,
- private val tcpSpec: Lazy,
-) {
- internal val nopInterface by lazy { nopInterfaceFactory.create("") }
-
- private val specMap: Map>
- get() =
- mapOf(
- InterfaceId.MOCK to mockSpec.value,
- InterfaceId.NOP to NopInterfaceSpec(nopInterfaceFactory),
- InterfaceId.SERIAL to serialSpec.value,
- InterfaceId.TCP to tcpSpec.value,
- )
-
- fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
-
- fun createInterface(address: String, service: RadioInterfaceService): RadioTransport {
- val (spec, rest) = splitAddress(address)
- return spec?.createInterface(rest, service) ?: nopInterface
- }
-
- fun addressValid(address: String?): Boolean = address?.let {
- val (spec, rest) = splitAddress(it)
- spec?.addressValid(rest)
- } ?: false
-
- private fun splitAddress(address: String): Pair?, String> {
- if (address.isEmpty()) return Pair(null, "")
- val c = address[0].let { InterfaceId.forIdChar(it) }?.let { specMap[it] }
- val rest = address.substring(1)
- return Pair(c, rest)
- }
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt
deleted file mode 100644
index f8c53313b..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt
+++ /dev/null
@@ -1,28 +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.core.network.radio
-
-import org.koin.core.annotation.Single
-import org.meshtastic.core.network.repository.UsbRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-
-/** Factory for creating `SerialInterface` instances. */
-@Single
-class SerialInterfaceFactory(private val usbRepository: UsbRepository) {
- fun create(rest: String, service: RadioInterfaceService): SerialInterface =
- SerialInterface(service, usbRepository, rest)
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt
deleted file mode 100644
index 8597fd060..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt
+++ /dev/null
@@ -1,51 +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.core.network.radio
-
-import android.hardware.usb.UsbManager
-import com.hoho.android.usbserial.driver.UsbSerialDriver
-import org.koin.core.annotation.Single
-import org.meshtastic.core.network.repository.UsbRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-
-/** Serial/USB interface backend implementation. */
-@Single
-class SerialInterfaceSpec(
- private val factory: SerialInterfaceFactory,
- private val usbManager: UsbManager,
- private val usbRepository: UsbRepository,
-) : InterfaceSpec {
- override fun createInterface(rest: String, service: RadioInterfaceService): SerialInterface =
- factory.create(rest, service)
-
- override fun addressValid(rest: String): Boolean {
- usbRepository.serialDevices.value.filterValues { usbManager.hasPermission(it.device) }
- findSerial(rest)?.let { d ->
- return usbManager.hasPermission(d.device)
- }
- return false
- }
-
- internal fun findSerial(rest: String): UsbSerialDriver? {
- val deviceMap = usbRepository.serialDevices.value
- return if (deviceMap.containsKey(rest)) {
- deviceMap[rest]!!
- } else {
- deviceMap.map { (_, driver) -> driver }.firstOrNull()
- }
- }
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
similarity index 76%
rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt
rename to core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
index e57c4a446..0f7985276 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
@@ -17,40 +17,39 @@
package org.meshtastic.core.network.radio
import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CoroutineScope
+import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.network.repository.SerialConnection
import org.meshtastic.core.network.repository.SerialConnectionListener
import org.meshtastic.core.network.repository.UsbRepository
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.ToRadio
+import org.meshtastic.core.network.transport.HeartbeatSender
+import org.meshtastic.core.repository.RadioTransportCallback
import java.util.concurrent.atomic.AtomicReference
-/** An interface that assumes we are talking to a meshtastic device via USB serial */
-class SerialInterface(
- service: RadioInterfaceService,
+/** An Android USB/serial [RadioTransport] implementation. */
+class SerialRadioTransport(
+ callback: RadioTransportCallback,
+ scope: CoroutineScope,
private val usbRepository: UsbRepository,
private val address: String,
-) : StreamInterface(service) {
+) : StreamTransport(callback, scope) {
private var connRef = AtomicReference()
- init {
+ private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]")
+
+ override fun start() {
connect()
}
- override fun onDeviceDisconnect(waitForStopped: Boolean) {
+ override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) {
connRef.get()?.close(waitForStopped)
- super.onDeviceDisconnect(waitForStopped)
+ super.onDeviceDisconnect(waitForStopped, isPermanent)
}
override fun connect() {
val deviceMap = usbRepository.serialDevices.value
- val device =
- if (deviceMap.containsKey(address)) {
- deviceMap[address]!!
- } else {
- deviceMap.map { (_, driver) -> driver }.firstOrNull()
- }
+ val device = deviceMap[address] ?: deviceMap.values.firstOrNull()
if (device == null) {
Logger.e { "[$address] Serial device not found at address" }
} else {
@@ -109,7 +108,10 @@ class SerialInterface(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
- onDeviceDisconnect(false)
+ // USB unplug / cable error is transient — the transport will reconnect when
+ // the device is replugged or the OS re-enumerates the port. Only an explicit
+ // close() (user disconnects) should signal a permanent disconnect.
+ onDeviceDisconnect(waitForStopped = false, isPermanent = false)
}
},
)
@@ -121,14 +123,9 @@ class SerialInterface(
}
override fun keepAlive() {
- // Send a ToRadio heartbeat so the firmware resets its idle timer and responds with
- // a FromRadio queueStatus — proving the serial link is alive. Without this, the
- // serial transport has no way to detect a silently dead device (battery depleted,
- // firmware crash without the `rebooted` flag). The queueStatus response also feeds
- // into MeshMessageProcessorImpl.refreshLocalNodeLastHeard() to keep the local
- // node's lastHeard timestamp current.
- Logger.d { "[$address] Serial keepAlive — sending heartbeat" }
- handleSendToRadio(ToRadio(heartbeat = Heartbeat()).encode())
+ // Delegate to HeartbeatSender which sends a ToRadio heartbeat to prove the serial
+ // link is alive and keep the local node's lastHeard timestamp current.
+ scope.handledLaunch { heartbeatSender.sendHeartbeat() }
}
override fun sendBytes(p: ByteArray) {
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt
deleted file mode 100644
index 003294448..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt
+++ /dev/null
@@ -1,27 +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.core.network.radio
-
-import org.koin.core.annotation.Single
-import org.meshtastic.core.di.CoroutineDispatchers
-import org.meshtastic.core.repository.RadioInterfaceService
-
-/** Factory for creating `TCPInterface` instances. */
-@Single
-class TCPInterfaceFactory(private val dispatchers: CoroutineDispatchers) {
- fun create(rest: String, service: RadioInterfaceService): TCPInterface = TCPInterface(service, dispatchers, rest)
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt
deleted file mode 100644
index 2539bc13c..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt
+++ /dev/null
@@ -1,27 +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.core.network.radio
-
-import org.koin.core.annotation.Single
-import org.meshtastic.core.repository.RadioInterfaceService
-
-/** TCP interface backend implementation. */
-@Single
-class TCPInterfaceSpec(private val factory: TCPInterfaceFactory) : InterfaceSpec {
- override fun createInterface(rest: String, service: RadioInterfaceService): TCPInterface =
- factory.create(rest, service)
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
index b2ccf6545..d8b14be03 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/SerialConnectionImpl.kt
@@ -87,6 +87,11 @@ internal class SerialConnectionImpl(
port.open(usbDeviceConnection)
port.setParameters(115200, UsbSerialPort.DATABITS_8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)
+
+ // Assert DTR/RTS so native USB-CDC firmware (RAK4631 / nRF52840) recognizes the host as
+ // present and starts its serial-side Meshtastic protocol. Empirically, omitting these
+ // signals causes the firmware to never respond to WAKE_BYTES, stalling the handshake at
+ // Stage 1. Bridge-chip boards (CH340, CP210x, FTDI) tolerate the assertion.
port.dtr = true
port.rts = true
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt
deleted file mode 100644
index 720d2a522..000000000
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt
+++ /dev/null
@@ -1,31 +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.core.network.repository
-
-import android.annotation.SuppressLint
-import java.security.cert.X509Certificate
-import javax.net.ssl.X509TrustManager
-
-@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager")
-@Suppress("EmptyFunctionBlock")
-class TrustAllX509TrustManager : X509TrustManager {
- override fun checkClientTrusted(chain: Array?, authType: String?) {}
-
- override fun checkServerTrusted(chain: Array?, authType: String?) {}
-
- override fun getAcceptedIssuers(): Array = arrayOf()
-}
diff --git a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
index b4773dff3..c5080ec14 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/UsbRepository.kt
@@ -54,9 +54,7 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap {
- serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
- }
+ buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -83,6 +81,8 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
- private suspend fun refreshStateInternal() =
- withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
+ private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
+ val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
+ _serialDevices.emit(devices)
+ }
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
new file mode 100644
index 000000000..87c317024
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
@@ -0,0 +1,34 @@
+/*
+ * 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.network
+
+/**
+ * Shared HTTP client configuration constants used by both Android and Desktop Ktor `HttpClient` setups.
+ *
+ * These values are consumed by the platform-specific Koin modules (`NetworkModule` on Android, `DesktopKoinModule` on
+ * Desktop) when installing [io.ktor.client.plugins.HttpTimeout] and [io.ktor.client.plugins.HttpRequestRetry].
+ */
+object HttpClientDefaults {
+ /** Timeout in milliseconds for connect, request, and socket operations. */
+ const val TIMEOUT_MS = 30_000L
+
+ /** Maximum number of automatic retries on server errors (5xx). */
+ const val MAX_RETRIES = 3
+
+ /** Base URL for the Meshtastic public API. Installed via the `DefaultRequest` plugin. */
+ const val API_BASE_URL = "https://api.meshtastic.org/"
+}
diff --git a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt
similarity index 51%
rename from core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
rename to core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt
index a450b9856..cabeb977a 100644
--- a/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/KermitHttpLogger.kt
@@ -14,7 +14,27 @@
* 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.core.network
-/** JVM/Android implementation of string formatting. */
-actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
+import co.touchlab.kermit.Logger
+import io.ktor.client.plugins.logging.Logger as KtorLogger
+
+/**
+ * Bridges Ktor's HTTP client logging to [Kermit][Logger] so HTTP request/response events appear in the standard app
+ * logs rather than going to [System.out] via Ktor's default [io.ktor.client.plugins.logging.Logger.DEFAULT].
+ *
+ * Usage:
+ * ```
+ * HttpClient(engine) {
+ * install(Logging) {
+ * logger = KermitHttpLogger
+ * level = LogLevel.HEADERS
+ * }
+ * }
+ * ```
+ */
+object KermitHttpLogger : KtorLogger {
+ override fun log(message: String) {
+ Logger.d { message }
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
index 2c5a02784..55856abf9 100644
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt
@@ -38,40 +38,41 @@ abstract class BaseRadioTransportFactory(
override fun isAddressValid(address: String?): Boolean {
val spec = address?.firstOrNull() ?: return false
- return spec in
- listOf(InterfaceId.TCP.id, InterfaceId.SERIAL.id, InterfaceId.BLUETOOTH.id, InterfaceId.MOCK.id) ||
- spec == '!' ||
- isPlatformAddressValid(address)
+ return when (spec) {
+ InterfaceId.TCP.id,
+ InterfaceId.SERIAL.id,
+ InterfaceId.BLUETOOTH.id,
+ InterfaceId.MOCK.id,
+ '!',
+ -> true
+ else -> isPlatformAddressValid(address)
+ }
}
protected open fun isPlatformAddressValid(address: String): Boolean = false
override fun toInterfaceAddress(interfaceId: InterfaceId, rest: String): String = "${interfaceId.id}$rest"
- override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport = when {
- address.startsWith(InterfaceId.BLUETOOTH.id) -> {
- BleRadioInterface(
- serviceScope = service.serviceScope,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- service = service,
- address = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()),
- )
- }
- address.startsWith("!") -> {
- BleRadioInterface(
- serviceScope = service.serviceScope,
- scanner = scanner,
- bluetoothRepository = bluetoothRepository,
- connectionFactory = connectionFactory,
- service = service,
- address = address.removePrefix("!"),
- )
- }
- else -> createPlatformTransport(address, service)
+ override fun createTransport(address: String, service: RadioInterfaceService): RadioTransport {
+ val transport =
+ when {
+ address.startsWith(InterfaceId.BLUETOOTH.id) || address.startsWith("!") -> {
+ val bleAddress = address.removePrefix(InterfaceId.BLUETOOTH.id.toString()).removePrefix("!")
+ BleRadioTransport(
+ scope = service.serviceScope,
+ scanner = scanner,
+ bluetoothRepository = bluetoothRepository,
+ connectionFactory = connectionFactory,
+ callback = service,
+ address = bleAddress,
+ )
+ }
+ else -> createPlatformTransport(address, service)
+ }
+ transport.start()
+ return transport
}
- /** Delegate to platform for Mock, TCP, or Serial/USB interfaces. */
+ /** Delegate to platform for Mock, TCP, or Serial/USB transports. */
protected abstract fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport
}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt
deleted file mode 100644
index 9942eec87..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioInterface.kt
+++ /dev/null
@@ -1,541 +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 .
- */
-@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
-
-package org.meshtastic.core.network.radio
-
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withTimeoutOrNull
-import org.meshtastic.core.ble.BleConnection
-import org.meshtastic.core.ble.BleConnectionFactory
-import org.meshtastic.core.ble.BleConnectionState
-import org.meshtastic.core.ble.BleDevice
-import org.meshtastic.core.ble.BleScanner
-import org.meshtastic.core.ble.BleWriteType
-import org.meshtastic.core.ble.BluetoothRepository
-import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
-import org.meshtastic.core.ble.retryBleOperation
-import org.meshtastic.core.ble.toMeshtasticRadioProfile
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.core.model.RadioNotConnectedException
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-import org.meshtastic.proto.Heartbeat
-import org.meshtastic.proto.ToRadio
-import kotlin.concurrent.Volatile
-import kotlin.concurrent.atomics.AtomicInt
-import kotlin.concurrent.atomics.ExperimentalAtomicApi
-import kotlin.time.Duration.Companion.seconds
-
-private const val SCAN_RETRY_COUNT = 3
-private const val SCAN_RETRY_DELAY_MS = 1000L
-private const val CONNECTION_TIMEOUT_MS = 15_000L
-private const val RECONNECT_FAILURE_THRESHOLD = 3
-private const val RECONNECT_BASE_DELAY_MS = 5_000L
-private const val RECONNECT_MAX_DELAY_MS = 60_000L
-private const val RECONNECT_MAX_FAILURES = 10
-
-/**
- * Minimum milliseconds a BLE connection must stay up before we consider it "stable" and reset
- * [BleRadioInterface.consecutiveFailures]. Without this, a device at the edge of BLE range can repeatedly connect for a
- * fraction of a second and drop — each brief connection resets the failure counter so [RECONNECT_FAILURE_THRESHOLD] is
- * never reached, and the app never signals [ConnectionState.DeviceSleep].
- *
- * The value (5 s) is long enough that only connections that survive past the initial GATT setup are treated as genuine,
- * but short enough that normal reconnects after light-sleep still reset the counter promptly.
- */
-private const val MIN_STABLE_CONNECTION_MS = 5_000L
-
-/**
- * Returns the reconnect backoff delay in milliseconds for a given consecutive failure count.
- *
- * Backoff schedule: 1 failure → 5 s 2 failures → 10 s 3 failures → 20 s 4 failures → 40 s 5+ failures → 60 s (capped)
- */
-internal fun computeReconnectBackoffMs(consecutiveFailures: Int): Long {
- if (consecutiveFailures <= 0) return RECONNECT_BASE_DELAY_MS
- return minOf(RECONNECT_BASE_DELAY_MS * (1L shl (consecutiveFailures - 1).coerceAtMost(4)), RECONNECT_MAX_DELAY_MS)
-}
-
-// Milliseconds to wait after launching characteristic observations before triggering the
-// Meshtastic handshake. Both fromRadio and logRadio observation flows write the CCCD
-// asynchronously via Kable's GATT queue. Without this settle window the want_config_id
-// burst from the radio can arrive before notifications are enabled, causing the first
-// handshake attempt to look like a stall.
-private const val CCCD_SETTLE_MS = 50L
-
-private val SCAN_TIMEOUT = 5.seconds
-private val GATT_CLEANUP_TIMEOUT = 5.seconds
-
-/**
- * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
- *
- * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
- * - Bonding and discovery.
- * - Automatic reconnection logic.
- * - MTU and connection parameter monitoring.
- * - Routing raw byte packets between the radio and [RadioInterfaceService].
- *
- * @param serviceScope The coroutine scope to use for launching coroutines.
- * @param scanner The BLE scanner.
- * @param bluetoothRepository The Bluetooth repository.
- * @param connectionFactory The BLE connection factory.
- * @param service The [RadioInterfaceService] to use for handling radio events.
- * @param address The BLE address of the device to connect to.
- */
-class BleRadioInterface(
- private val serviceScope: CoroutineScope,
- private val scanner: BleScanner,
- private val bluetoothRepository: BluetoothRepository,
- private val connectionFactory: BleConnectionFactory,
- private val service: RadioInterfaceService,
- val address: String,
-) : RadioTransport {
-
- private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
- Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
- serviceScope.launch {
- try {
- bleConnection.disconnect()
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
- }
- }
- val (isPermanent, msg) = throwable.toDisconnectReason()
- service.onDisconnect(isPermanent, errorMessage = msg)
- }
-
- private val connectionScope: CoroutineScope =
- CoroutineScope(
- serviceScope.coroutineContext + SupervisorJob(serviceScope.coroutineContext.job) + exceptionHandler,
- )
- private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
- private val writeMutex: Mutex = Mutex()
-
- private var connectionStartTime: Long = 0
- private var packetsReceived: Int = 0
- private var packetsSent: Int = 0
- private var bytesReceived: Long = 0
- private var bytesSent: Long = 0
-
- @Volatile private var isFullyConnected = false
- private var connectionJob: Job? = null
- private var consecutiveFailures = 0
-
- @OptIn(ExperimentalAtomicApi::class)
- private val heartbeatNonce = AtomicInt(0)
-
- init {
- connect()
- }
-
- // --- Connection & Discovery Logic ---
-
- /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
- private suspend fun findDevice(): BleDevice {
- bluetoothRepository.state.value.bondedDevices
- .firstOrNull { it.address.equals(address, ignoreCase = true) }
- ?.let {
- return it
- }
-
- Logger.i { "[$address] Device not found in bonded list, scanning" }
-
- repeat(SCAN_RETRY_COUNT) { attempt ->
- try {
- val d =
- withTimeoutOrNull(SCAN_TIMEOUT) {
- scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
- it.address.equals(address, ignoreCase = true)
- }
- }
- if (d != null) return d
- } catch (e: Exception) {
- Logger.v(e) { "[$address] Scan attempt failed or timed out" }
- }
-
- if (attempt < SCAN_RETRY_COUNT - 1) {
- delay(SCAN_RETRY_DELAY_MS)
- }
- }
-
- throw RadioNotConnectedException("Device not found at address $address")
- }
-
- @Suppress("LongMethod", "CyclomaticComplexMethod")
- private fun connect() {
- connectionJob =
- connectionScope.launch {
- while (isActive) {
- try {
- // Allow any pending background disconnects to complete and the Android BLE stack
- // to settle before we attempt a new connection.
- @Suppress("MagicNumber")
- val connectDelayMs = 1000L
- delay(connectDelayMs)
-
- connectionStartTime = nowMillis
- Logger.i { "[$address] BLE connection attempt started" }
-
- val device = findDevice()
-
- // Ensure the device is bonded before connecting. On Android, the
- // firmware may require an encrypted link (pairing mode != NO_PIN).
- // Without an explicit bond the GATT connection will fail with
- // insufficient-authentication (status 5) or the dreaded status 133.
- // On Desktop/JVM this is a no-op since the OS handles pairing during
- // the GATT connection when the peripheral requires it.
- if (!bluetoothRepository.isBonded(address)) {
- Logger.i { "[$address] Device not bonded, initiating bonding" }
- @Suppress("TooGenericExceptionCaught")
- try {
- bluetoothRepository.bond(device)
- Logger.i { "[$address] Bonding successful" }
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
- }
- }
-
- var state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
-
- if (state !is BleConnectionState.Connected) {
- // Kable on Android occasionally fails the first connection attempt with
- // NotConnectedException if the previous peripheral wasn't fully cleaned
- // up by the OS. A quick retry resolves it.
- Logger.d { "[$address] First connection attempt failed, retrying in 1.5s" }
- @Suppress("MagicNumber")
- delay(1500L)
- state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT_MS)
- }
-
- if (state !is BleConnectionState.Connected) {
- throw RadioNotConnectedException("Failed to connect to device at address $address")
- }
-
- // Connection succeeded — only reset the failure counter if the
- // connection stays up long enough. See MIN_STABLE_CONNECTION_MS.
- val gattConnectedAt = nowMillis
- isFullyConnected = true
- onConnected()
-
- // Use coroutineScope so that the connectionState listener is scoped to this
- // iteration only. When the inner scope exits (on disconnect), the listener is
- // cancelled automatically before the next reconnect cycle starts a fresh one.
- coroutineScope {
- bleConnection.connectionState
- .onEach { s ->
- if (s is BleConnectionState.Disconnected && isFullyConnected) {
- isFullyConnected = false
- onDisconnected()
- }
- }
- .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
- .launchIn(this)
-
- discoverServicesAndSetupCharacteristics()
-
- // Suspend here until Kable drops the connection
- bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
- }
-
- Logger.i { "[$address] BLE connection dropped, preparing to reconnect" }
-
- // Only reset the failure counter if the connection was stable (lasted
- // longer than MIN_STABLE_CONNECTION_MS). A connection that drops within
- // seconds typically means the device is at the edge of BLE range or
- // powered off — the Android BLE stack may briefly "connect" to a cached
- // GATT profile before realising the device is gone. Without this guard,
- // the failure counter resets on every brief connect, preventing us from
- // ever reaching RECONNECT_FAILURE_THRESHOLD and signalling DeviceSleep.
- val connectionUptime = nowMillis - gattConnectedAt
- if (connectionUptime >= MIN_STABLE_CONNECTION_MS) {
- consecutiveFailures = 0
- } else {
- consecutiveFailures++
- Logger.w {
- "[$address] Connection lasted only ${connectionUptime}ms " +
- "(< ${MIN_STABLE_CONNECTION_MS}ms) — treating as failure " +
- "(consecutive failures: $consecutiveFailures)"
- }
- if (consecutiveFailures >= RECONNECT_MAX_FAILURES) {
- Logger.e { "[$address] Giving up after $consecutiveFailures unstable connections" }
- service.onDisconnect(
- isPermanent = true,
- errorMessage = "Device unreachable (unstable connection)",
- )
- return@launch
- }
- if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
- service.onDisconnect(
- isPermanent = false,
- errorMessage = "Device unreachable (unstable connection)",
- )
- }
- }
- } catch (e: kotlinx.coroutines.CancellationException) {
- Logger.d { "[$address] BLE connection coroutine cancelled" }
- throw e
- } catch (e: Exception) {
- val failureTime = nowMillis - connectionStartTime
- consecutiveFailures++
- Logger.w(e) {
- "[$address] Failed to connect to device after ${failureTime}ms " +
- "(consecutive failures: $consecutiveFailures)"
- }
-
- // After exceeding the max failure limit, give up permanently to stop
- // draining battery on a device that is genuinely offline. The user
- // must manually reconnect from the connections screen.
- if (consecutiveFailures >= RECONNECT_MAX_FAILURES) {
- Logger.e { "[$address] Giving up after $consecutiveFailures consecutive failures" }
- val (_, msg) = e.toDisconnectReason()
- service.onDisconnect(isPermanent = true, errorMessage = msg)
- return@launch
- }
-
- // At the failure threshold, signal DeviceSleep so
- // MeshConnectionManagerImpl can start its sleep timeout.
- if (consecutiveFailures >= RECONNECT_FAILURE_THRESHOLD) {
- handleFailure(e)
- }
-
- // Exponential backoff: 5s → 10s → 20s → 40s → capped at 60s.
- // Reduces BLE stack pressure and battery drain when the device is genuinely
- // out of range, while still recovering quickly from transient drops.
- val backoffMs = computeReconnectBackoffMs(consecutiveFailures)
- Logger.d { "[$address] Retrying in ${backoffMs}ms (failure #$consecutiveFailures)" }
- delay(backoffMs)
- }
- }
- }
- }
-
- private suspend fun onConnected() {
- try {
- bleConnection.deviceFlow.first()?.let { device ->
- val rssi = retryBleOperation(tag = address) { device.readRssi() }
- Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
- }
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
- }
- }
-
- private fun onDisconnected() {
- radioService = null
-
- val uptime =
- if (connectionStartTime > 0) {
- nowMillis - connectionStartTime
- } else {
- 0
- }
- Logger.i {
- "[$address] BLE disconnected - " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
- }
- // Signal DeviceSleep immediately so the UI reflects the disconnect while the
- // reconnect loop continues in the background. The previous approach suppressed
- // this signal until RECONNECT_FAILURE_THRESHOLD consecutive failures, leaving the
- // UI stuck on "Connected" for 35+ seconds after the device disappeared.
- service.onDisconnect(isPermanent = false)
- }
-
- private suspend fun discoverServicesAndSetupCharacteristics() {
- try {
- bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
- val radioService = service.toMeshtasticRadioProfile()
-
- // Wire up notifications
- radioService.fromRadio
- .onEach { packet ->
- Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { e ->
- Logger.w(e) { "[$address] Error in fromRadio flow" }
- handleFailure(e)
- }
- .launchIn(this)
-
- radioService.logRadio
- .onEach { packet ->
- Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
- dispatchPacket(packet)
- }
- .catch { e ->
- Logger.w(e) { "[$address] Error in logRadio flow" }
- handleFailure(e)
- }
- .launchIn(this)
-
- // Store reference for handleSendToRadio
- this@BleRadioInterface.radioService = radioService
-
- Logger.i { "[$address] Profile service active and characteristics subscribed" }
-
- // Give Kable's async CCCD writes time to complete before triggering the
- // Meshtastic handshake. The fromRadio/logRadio observation flows register
- // notifications through the GATT queue asynchronously. Without this settle
- // window, the want_config_id burst arrives before notifications are enabled.
- delay(CCCD_SETTLE_MS)
-
- // Log negotiated MTU for diagnostics
- val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
- Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
-
- this@BleRadioInterface.service.onConnect()
- }
- } catch (e: Exception) {
- Logger.w(e) { "[$address] Profile service discovery or operation failed" }
- // Ensure the peripheral is disconnected so the outer reconnect loop sees a clean
- // Disconnected state. Do NOT call handleFailure here — the reconnect loop tracks
- // consecutive failures and calls handleFailure after RECONNECT_FAILURE_THRESHOLD,
- // preventing premature onDisconnect signals to the service on transient errors.
- try {
- bleConnection.disconnect()
- } catch (ignored: Exception) {
- Logger.w(ignored) { "[$address] disconnect() failed after profile error" }
- }
- }
- }
-
- @Volatile private var radioService: org.meshtastic.core.ble.MeshtasticRadioProfile? = null
-
- // --- RadioTransport Implementation ---
-
- /**
- * Sends a packet to the radio with retry support.
- *
- * @param p The packet to send.
- */
- override fun handleSendToRadio(p: ByteArray) {
- val currentService = radioService
- if (currentService != null) {
- connectionScope.launch {
- writeMutex.withLock {
- try {
- retryBleOperation(tag = address) { currentService.sendToRadio(p) }
- packetsSent++
- bytesSent += p.size
- Logger.v {
- "[$address] Wrote packet #$packetsSent " +
- "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
- }
- } catch (e: Exception) {
- Logger.w(e) {
- "[$address] Failed to write packet to toRadioCharacteristic after " +
- "$packetsSent successful writes"
- }
- handleFailure(e)
- }
- }
- }
- } else {
- Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
- }
- }
-
- @OptIn(ExperimentalAtomicApi::class)
- override fun keepAlive() {
- // Send a ToRadio heartbeat so the firmware resets its power-saving idle timer.
- // The firmware only resets the timer on writes to the TORADIO characteristic; a
- // BLE-level GATT keepalive is invisible to it. Without this the device may enter
- // light-sleep and drop the BLE connection after ~60 s of application inactivity.
- //
- // Each heartbeat uses a distinct nonce to vary the wire bytes, preventing the
- // firmware's per-connection duplicate-write filter from silently dropping it.
- val nonce = heartbeatNonce.fetchAndAdd(1)
- Logger.v { "[$address] BLE keepAlive — sending ToRadio heartbeat (nonce=$nonce)" }
- handleSendToRadio(ToRadio(heartbeat = Heartbeat(nonce = nonce)).encode())
- }
-
- /** Closes the connection to the device. */
- override fun close() {
- val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
- Logger.i {
- "[$address] Disconnecting. " +
- "Uptime: ${uptime}ms, " +
- "Packets RX: $packetsReceived ($bytesReceived bytes), " +
- "Packets TX: $packetsSent ($bytesSent bytes)"
- }
- // Cancel the connection scope to break the while(isActive) reconnect loop.
- connectionScope.cancel("close() called")
- // GATT cleanup must survive serviceScope cancellation. SharedRadioInterfaceService calls
- // close() and then immediately cancels serviceScope — a coroutine launched on serviceScope
- // may never be dispatched, leaving the BluetoothGatt object leaked (causes GATT 133 on the
- // next connect attempt). GlobalScope is the correct tool here: the cleanup is short-lived,
- // fire-and-forget, and must outlive any application-managed scope.
- // onDisconnect is handled by SharedRadioInterfaceService.stopInterfaceLocked() directly.
- @OptIn(DelicateCoroutinesApi::class)
- GlobalScope.launch {
- try {
- withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
- } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
- Logger.w(e) { "[$address] Failed to disconnect in close()" }
- }
- }
- }
-
- private fun dispatchPacket(packet: ByteArray) {
- packetsReceived++
- bytesReceived += packet.size
- Logger.v {
- "[$address] Dispatching packet #$packetsReceived " +
- "(${packet.size} bytes, total RX: $bytesReceived bytes)"
- }
- service.handleFromRadio(packet)
- }
-
- private fun handleFailure(throwable: Throwable) {
- val (isPermanent, msg) = throwable.toDisconnectReason()
- service.onDisconnect(isPermanent, errorMessage = msg)
- }
-
- private fun Throwable.toDisconnectReason(): Pair {
- val isPermanent =
- this::class.simpleName == "BluetoothUnavailableException" ||
- this::class.simpleName == "ManagerClosedException"
- val msg =
- when {
- this is RadioNotConnectedException -> this.message ?: "Device not found"
- this is NoSuchElementException || this is IllegalArgumentException -> "Required characteristic missing"
- this::class.simpleName == "GattException" -> "GATT Error: ${this.message}"
- else -> this.message ?: this::class.simpleName ?: "Unknown"
- }
- return Pair(isPermanent, msg)
- }
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
new file mode 100644
index 000000000..f2ba25804
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt
@@ -0,0 +1,457 @@
+/*
+ * 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 .
+ */
+@file:Suppress("TooManyFunctions", "TooGenericExceptionCaught")
+
+package org.meshtastic.core.network.radio
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.job
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
+import org.meshtastic.core.ble.BleConnection
+import org.meshtastic.core.ble.BleConnectionFactory
+import org.meshtastic.core.ble.BleConnectionState
+import org.meshtastic.core.ble.BleDevice
+import org.meshtastic.core.ble.BleScanner
+import org.meshtastic.core.ble.BleWriteType
+import org.meshtastic.core.ble.BluetoothRepository
+import org.meshtastic.core.ble.DisconnectReason
+import org.meshtastic.core.ble.MeshtasticBleConstants.SERVICE_UUID
+import org.meshtastic.core.ble.MeshtasticRadioProfile
+import org.meshtastic.core.ble.classifyBleException
+import org.meshtastic.core.ble.retryBleOperation
+import org.meshtastic.core.ble.toMeshtasticRadioProfile
+import org.meshtastic.core.common.util.nowMillis
+import org.meshtastic.core.model.RadioNotConnectedException
+import org.meshtastic.core.network.transport.HeartbeatSender
+import org.meshtastic.core.repository.RadioTransport
+import org.meshtastic.core.repository.RadioTransportCallback
+import kotlin.concurrent.Volatile
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+private const val SCAN_RETRY_COUNT = 3
+private val SCAN_RETRY_DELAY = 1.seconds
+private val CONNECTION_TIMEOUT = 15.seconds
+
+/**
+ * Delay after writing a heartbeat before re-polling FROMRADIO.
+ *
+ * The ESP32 firmware processes TORADIO writes asynchronously (NimBLE callback → FreeRTOS main task queue →
+ * `handleToRadio()` → `heartbeatReceived = true`). The immediate drain trigger in
+ * [KableMeshtasticRadioProfile.sendToRadio] fires before this completes, so the `queueStatus` response is not yet
+ * available. 200 ms is well above observed ESP32 task scheduling latency (~10–50 ms) while remaining imperceptible to
+ * the user.
+ */
+private val HEARTBEAT_DRAIN_DELAY = 200.milliseconds
+
+private val SCAN_TIMEOUT = 5.seconds
+private val GATT_CLEANUP_TIMEOUT = 5.seconds
+
+/**
+ * A [RadioTransport] implementation for BLE devices using the common BLE abstractions (which are powered by Kable).
+ *
+ * This class handles the high-level connection lifecycle for Meshtastic radios over BLE, including:
+ * - Bonding and discovery.
+ * - Automatic reconnection logic.
+ * - MTU and connection parameter monitoring.
+ * - Routing raw byte packets between the radio and [RadioTransportCallback].
+ *
+ * @param scope The coroutine scope to use for launching coroutines.
+ * @param scanner The BLE scanner.
+ * @param bluetoothRepository The Bluetooth repository.
+ * @param connectionFactory The BLE connection factory.
+ * @param callback The [RadioTransportCallback] to use for handling radio events.
+ * @param address The BLE address of the device to connect to.
+ */
+class BleRadioTransport(
+ private val scope: CoroutineScope,
+ private val scanner: BleScanner,
+ private val bluetoothRepository: BluetoothRepository,
+ private val connectionFactory: BleConnectionFactory,
+ private val callback: RadioTransportCallback,
+ internal val address: String,
+) : RadioTransport {
+
+ private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+ Logger.w(throwable) { "[$address] Uncaught exception in connectionScope" }
+ scope.launch {
+ try {
+ bleConnection.disconnect()
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Failed to disconnect in exception handler" }
+ }
+ }
+ val (isPermanent, msg) = throwable.toDisconnectReason()
+ callback.onDisconnect(isPermanent, errorMessage = msg)
+ }
+
+ private val connectionScope: CoroutineScope =
+ CoroutineScope(scope.coroutineContext + SupervisorJob(scope.coroutineContext.job) + exceptionHandler)
+ private val bleConnection: BleConnection = connectionFactory.create(connectionScope, address)
+ private val writeMutex: Mutex = Mutex()
+
+ @Volatile private var connectionStartTime: Long = 0
+
+ @Volatile private var packetsReceived: Int = 0
+
+ @Volatile private var packetsSent: Int = 0
+
+ @Volatile private var bytesReceived: Long = 0
+
+ @Volatile private var bytesSent: Long = 0
+
+ @Volatile private var isFullyConnected = false
+ private var connectionJob: Job? = null
+
+ // Never give up while the user has this device selected. Higher layers (SharedRadioInterfaceService)
+ // own the explicit-disconnect lifecycle and will close() us when the user picks a different device or
+ // toggles the connection off; until then, retry forever with the policy's exponential-backoff cap (60 s).
+ private val reconnectPolicy = BleReconnectPolicy(maxFailures = Int.MAX_VALUE)
+
+ private val heartbeatSender =
+ HeartbeatSender(
+ sendToRadio = ::handleSendToRadio,
+ afterHeartbeat = {
+ delay(HEARTBEAT_DRAIN_DELAY)
+ radioService?.requestDrain()
+ },
+ logTag = address,
+ )
+
+ override fun start() {
+ connect()
+ }
+
+ // --- Connection & Discovery Logic ---
+
+ /** Robustly finds the device. First checks bonded devices, then performs a short scan if not found. */
+ private suspend fun findDevice(): BleDevice {
+ bluetoothRepository.state.value.bondedDevices
+ .firstOrNull { it.address.equals(address, ignoreCase = true) }
+ ?.let {
+ return it
+ }
+
+ Logger.i { "[$address] Device not found in bonded list, scanning" }
+
+ repeat(SCAN_RETRY_COUNT) { attempt ->
+ try {
+ val d =
+ withTimeoutOrNull(SCAN_TIMEOUT) {
+ scanner.scan(timeout = SCAN_TIMEOUT, serviceUuid = SERVICE_UUID, address = address).first {
+ it.address.equals(address, ignoreCase = true)
+ }
+ }
+ if (d != null) return d
+ } catch (e: Exception) {
+ Logger.v(e) { "[$address] Scan attempt failed or timed out" }
+ }
+
+ if (attempt < SCAN_RETRY_COUNT - 1) {
+ delay(SCAN_RETRY_DELAY)
+ }
+ }
+
+ throw RadioNotConnectedException("Device not found at address $address")
+ }
+
+ private fun connect() {
+ connectionJob =
+ connectionScope.launch {
+ reconnectPolicy.execute(
+ attempt = {
+ try {
+ attemptConnection()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ val failureTime = (nowMillis - connectionStartTime).milliseconds
+ Logger.w(e) { "[$address] Failed to connect after $failureTime" }
+ BleReconnectPolicy.Outcome.Failed(e)
+ }
+ },
+ onTransientDisconnect = { error ->
+ val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
+ callback.onDisconnect(isPermanent = false, errorMessage = msg)
+ },
+ onPermanentDisconnect = { error ->
+ val msg = error?.toDisconnectReason()?.second ?: "Device unreachable"
+ callback.onDisconnect(isPermanent = true, errorMessage = msg)
+ },
+ )
+ }
+ }
+
+ /**
+ * Performs a single BLE connect-and-wait cycle.
+ *
+ * Finds the device, bonds if needed, connects, discovers services, and waits for disconnect. Returns a
+ * [BleReconnectPolicy.Outcome] describing how the connection ended.
+ */
+ @Suppress("CyclomaticComplexMethod")
+ private suspend fun attemptConnection(): BleReconnectPolicy.Outcome {
+ connectionStartTime = nowMillis
+ Logger.i { "[$address] BLE connection attempt started" }
+
+ val device = findDevice()
+
+ // Bond before connecting: firmware may require an encrypted link,
+ // and without a bond Android fails with status 5 or 133.
+ // No-op on Desktop/JVM where the OS handles pairing automatically.
+ if (!bluetoothRepository.isBonded(address)) {
+ Logger.i { "[$address] Device not bonded, initiating bonding" }
+ @Suppress("TooGenericExceptionCaught")
+ try {
+ bluetoothRepository.bond(device)
+ Logger.i { "[$address] Bonding successful" }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Bonding failed, attempting connection anyway" }
+ }
+ }
+
+ val state = bleConnection.connectAndAwait(device, CONNECTION_TIMEOUT)
+
+ if (state !is BleConnectionState.Connected) {
+ throw RadioNotConnectedException("Failed to connect to device at address $address")
+ }
+
+ val gattConnectedAt = nowMillis
+ isFullyConnected = true
+ onConnected()
+
+ // Scope the connectionState listener to this iteration so it's
+ // cancelled automatically before the next reconnect cycle.
+ var disconnectReason: DisconnectReason = DisconnectReason.Unknown
+ coroutineScope {
+ bleConnection.connectionState
+ .onEach { s ->
+ if (s is BleConnectionState.Disconnected && isFullyConnected) {
+ isFullyConnected = false
+ disconnectReason = s.reason
+ onDisconnected()
+ }
+ }
+ .catch { e -> Logger.w(e) { "[$address] bleConnection.connectionState flow crashed" } }
+ .launchIn(this)
+
+ discoverServicesAndSetupCharacteristics()
+
+ bleConnection.connectionState.first { it is BleConnectionState.Disconnected }
+ }
+
+ Logger.i { "[$address] BLE connection dropped (reason: $disconnectReason), preparing to reconnect" }
+
+ val wasIntentional = disconnectReason is DisconnectReason.LocalDisconnect
+ val connectionUptime = (nowMillis - gattConnectedAt).milliseconds
+ val wasStable = connectionUptime >= reconnectPolicy.minStableConnection
+
+ if (!wasStable && !wasIntentional) {
+ Logger.w {
+ "[$address] Connection lasted only $connectionUptime " +
+ "(< ${reconnectPolicy.minStableConnection}) — treating as unstable"
+ }
+ }
+
+ return BleReconnectPolicy.Outcome.Disconnected(wasStable = wasStable, wasIntentional = wasIntentional)
+ }
+
+ private suspend fun onConnected() {
+ try {
+ bleConnection.deviceFlow.first()?.let { device ->
+ val rssi = retryBleOperation(tag = address) { device.readRssi() }
+ Logger.d { "[$address] Connection confirmed. Initial RSSI: $rssi dBm" }
+ }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Failed to read initial connection RSSI" }
+ }
+ }
+
+ private fun onDisconnected() {
+ radioService = null
+ Logger.i { "[$address] BLE disconnected - ${formatSessionStats()}" }
+ // Signal immediately so the UI reflects the disconnect while reconnect continues.
+ callback.onDisconnect(isPermanent = false)
+ }
+
+ private suspend fun discoverServicesAndSetupCharacteristics() {
+ try {
+ bleConnection.profile(serviceUuid = SERVICE_UUID) { service ->
+ val radioService = service.toMeshtasticRadioProfile()
+
+ radioService.fromRadio
+ .onEach { packet ->
+ Logger.v { "[$address] Received packet fromRadio (${packet.size} bytes)" }
+ dispatchPacket(packet)
+ }
+ .catch { e ->
+ Logger.w(e) { "[$address] Error in fromRadio flow" }
+ handleFailure(e)
+ }
+ .launchIn(this)
+
+ radioService.logRadio
+ .onEach { packet ->
+ Logger.v { "[$address] Received packet logRadio (${packet.size} bytes)" }
+ dispatchPacket(packet)
+ }
+ .catch { e ->
+ Logger.w(e) { "[$address] Error in logRadio flow" }
+ handleFailure(e)
+ }
+ .launchIn(this)
+
+ this@BleRadioTransport.radioService = radioService
+
+ Logger.i { "[$address] Profile service active and characteristics subscribed" }
+
+ // Wait for FROMNUM CCCD write before triggering the Meshtastic handshake.
+ radioService.awaitSubscriptionReady()
+
+ // Log negotiated MTU for diagnostics
+ val maxLen = bleConnection.maximumWriteValueLength(BleWriteType.WITHOUT_RESPONSE)
+ Logger.i { "[$address] BLE Radio Session Ready. Max write length (WITHOUT_RESPONSE): $maxLen bytes" }
+
+ this@BleRadioTransport.callback.onConnect()
+ }
+ } catch (e: Exception) {
+ Logger.w(e) { "[$address] Profile service discovery or operation failed" }
+ // Disconnect to let the outer reconnect loop see a clean Disconnected state.
+ // Do NOT call handleFailure here — the reconnect loop owns failure counting.
+ try {
+ bleConnection.disconnect()
+ } catch (ignored: Exception) {
+ Logger.w(ignored) { "[$address] disconnect() failed after profile error" }
+ }
+ }
+ }
+
+ @Volatile private var radioService: MeshtasticRadioProfile? = null
+
+ // --- RadioTransport Implementation ---
+
+ /**
+ * Sends a packet to the radio with retry support.
+ *
+ * @param p The packet to send.
+ */
+ override fun handleSendToRadio(p: ByteArray) {
+ val currentService = radioService
+ if (currentService != null) {
+ connectionScope.launch {
+ writeMutex.withLock {
+ try {
+ retryBleOperation(tag = address) { currentService.sendToRadio(p) }
+ packetsSent++
+ bytesSent += p.size
+ Logger.v {
+ "[$address] Wrote packet #$packetsSent " +
+ "to toRadio (${p.size} bytes, total TX: $bytesSent bytes)"
+ }
+ } catch (e: Exception) {
+ Logger.w(e) {
+ "[$address] Failed to write packet to toRadioCharacteristic after " +
+ "$packetsSent successful writes"
+ }
+ handleFailure(e)
+ }
+ }
+ }
+ } else {
+ Logger.w { "[$address] toRadio characteristic unavailable, can't send data" }
+ }
+ }
+
+ override fun keepAlive() {
+ // Delegate to HeartbeatSender which sends a ToRadio heartbeat with a unique nonce
+ // so the firmware resets its power-saving idle timer. After sending, it schedules
+ // a delayed re-drain to pick up the queueStatus response.
+ connectionScope.launch { heartbeatSender.sendHeartbeat() }
+ }
+
+ /** Closes the connection to the device. */
+ override suspend fun close() {
+ Logger.i { "[$address] Disconnecting. ${formatSessionStats()}" }
+ connectionScope.cancel("close() called")
+ // GATT cleanup must run under NonCancellable so a cancelled caller cannot skip it,
+ // which would leak BluetoothGatt and trigger status 133 on the next reconnect.
+ // Using withContext (not runBlocking) keeps the caller's thread free — this is
+ // critical when close() is invoked from the main thread during process shutdown.
+ withContext(NonCancellable) {
+ try {
+ withTimeoutOrNull(GATT_CLEANUP_TIMEOUT) { bleConnection.disconnect() }
+ } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
+ Logger.w(e) { "[$address] Failed to disconnect in close()" }
+ }
+ }
+ }
+
+ private fun dispatchPacket(packet: ByteArray) {
+ packetsReceived++
+ bytesReceived += packet.size
+ Logger.v {
+ "[$address] Dispatching packet #$packetsReceived " +
+ "(${packet.size} bytes, total RX: $bytesReceived bytes)"
+ }
+ callback.handleFromRadio(packet)
+ }
+
+ private fun handleFailure(throwable: Throwable) {
+ val (isPermanent, msg) = throwable.toDisconnectReason()
+ callback.onDisconnect(isPermanent, errorMessage = msg)
+ }
+
+ /** Formats a one-line session statistics summary for logging. */
+ private fun formatSessionStats(): String {
+ val uptime = if (connectionStartTime > 0) nowMillis - connectionStartTime else 0
+ return "Uptime: ${uptime}ms, " +
+ "Packets RX: $packetsReceived ($bytesReceived bytes), " +
+ "Packets TX: $packetsSent ($bytesSent bytes)"
+ }
+
+ private fun Throwable.toDisconnectReason(): Pair {
+ classifyBleException()?.let {
+ return it.isPermanent to it.message
+ }
+
+ val msg =
+ when (this) {
+ is RadioNotConnectedException -> this.message ?: "Device not found"
+ is NoSuchElementException,
+ is IllegalArgumentException,
+ -> "Required characteristic missing"
+ else -> this.message ?: this::class.simpleName ?: "Unknown"
+ }
+ return false to msg
+ }
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
new file mode 100644
index 000000000..e4d250796
--- /dev/null
+++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt
@@ -0,0 +1,182 @@
+/*
+ * 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.network.radio
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlin.coroutines.coroutineContext
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Encapsulates the BLE reconnection policy with exponential backoff.
+ *
+ * The policy tracks consecutive failures and decides whether to retry or signal a transient disconnect (DeviceSleep).
+ * When [maxFailures] is reached the [execute] loop invokes [execute]'s `onPermanentDisconnect` callback and returns;
+ * set [maxFailures] to [Int.MAX_VALUE] (as [BleRadioTransport] does) to disable the give-up path entirely.
+ *
+ * @param maxFailures maximum consecutive failures before giving up; use [Int.MAX_VALUE] to retry indefinitely
+ * @param failureThreshold after this many consecutive failures, signal a transient disconnect
+ * @param settleDelay delay before each connection attempt to let the BLE stack settle
+ * @param minStableConnection minimum time a connection must stay up to be considered "stable"
+ * @param backoffStrategy computes the backoff delay for a given failure count
+ */
+class BleReconnectPolicy(
+ private val maxFailures: Int = DEFAULT_MAX_FAILURES,
+ private val failureThreshold: Int = DEFAULT_FAILURE_THRESHOLD,
+ private val settleDelay: Duration = DEFAULT_SETTLE_DELAY,
+ /** Minimum time a connection must stay up to be considered "stable". Exposed for callers to compare uptime. */
+ val minStableConnection: Duration = DEFAULT_MIN_STABLE_CONNECTION,
+ private val backoffStrategy: (attempt: Int) -> Duration = ::computeReconnectBackoff,
+) {
+ /** Outcome of a single reconnect iteration. */
+ sealed interface Outcome {
+ /** Connection attempt succeeded and then eventually disconnected. */
+ data class Disconnected(val wasStable: Boolean, val wasIntentional: Boolean) : Outcome
+
+ /** Connection attempt failed with an exception. */
+ data class Failed(val error: Throwable) : Outcome
+ }
+
+ /** Action the caller should take after the policy processes an outcome. */
+ sealed interface Action {
+ /** Retry the connection after the specified backoff delay. */
+ data class Retry(val backoff: Duration) : Action
+
+ /** Signal a transient disconnect to higher layers. */
+ data class SignalTransient(val backoff: Duration) : Action
+
+ /** Give up permanently. */
+ data object GiveUp : Action
+
+ /** Continue immediately (e.g. after an intentional disconnect). */
+ data object Continue : Action
+ }
+
+ internal var consecutiveFailures: Int = 0
+ private set
+
+ /** Processes the outcome of a connection attempt and returns the action the caller should take. */
+ fun processOutcome(outcome: Outcome): Action = when (outcome) {
+ is Outcome.Disconnected -> {
+ if (outcome.wasIntentional) {
+ consecutiveFailures = 0
+ Action.Continue
+ } else if (outcome.wasStable) {
+ consecutiveFailures = 0
+ Action.Continue
+ } else {
+ consecutiveFailures++
+ Logger.w { "Unstable connection (consecutive failures: $consecutiveFailures)" }
+ evaluateFailure()
+ }
+ }
+ is Outcome.Failed -> {
+ consecutiveFailures++
+ Logger.w { "Connection failed (consecutive failures: $consecutiveFailures)" }
+ evaluateFailure()
+ }
+ }
+
+ private fun evaluateFailure(): Action {
+ if (consecutiveFailures >= maxFailures) {
+ return Action.GiveUp
+ }
+ val backoff = backoffStrategy(consecutiveFailures)
+ return if (consecutiveFailures >= failureThreshold) {
+ Action.SignalTransient(backoff)
+ } else {
+ Action.Retry(backoff)
+ }
+ }
+
+ /**
+ * Runs the reconnect loop, calling [attempt] for each iteration.
+ *
+ * The [attempt] lambda should perform a single connect-and-wait cycle and return the [Outcome] when the connection
+ * drops or an error occurs.
+ *
+ * @param attempt performs a single connection attempt and returns the outcome
+ * @param onTransientDisconnect called when the policy decides to signal a transient disconnect
+ * @param onPermanentDisconnect called when the policy gives up permanently
+ */
+ suspend fun execute(
+ attempt: suspend () -> Outcome,
+ onTransientDisconnect: suspend (Throwable?) -> Unit,
+ onPermanentDisconnect: suspend (Throwable?) -> Unit,
+ ) {
+ while (coroutineContext.isActive) {
+ delay(settleDelay)
+
+ val outcome = attempt()
+ val lastError = (outcome as? Outcome.Failed)?.error
+
+ when (val action = processOutcome(outcome)) {
+ is Action.Continue -> continue
+ is Action.Retry -> {
+ Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" }
+ delay(action.backoff)
+ }
+ is Action.SignalTransient -> {
+ onTransientDisconnect(lastError)
+ Logger.d { "Retrying in ${action.backoff} (failure #$consecutiveFailures)" }
+ delay(action.backoff)
+ }
+ is Action.GiveUp -> {
+ Logger.e { "Giving up after $consecutiveFailures consecutive failures" }
+ onPermanentDisconnect(lastError)
+ return
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val DEFAULT_MAX_FAILURES = 10
+ const val DEFAULT_FAILURE_THRESHOLD = 3
+
+ /**
+ * Delay applied before every connection attempt (including the first) so the BLE stack and the firmware-side
+ * GATT session have time to settle.
+ *
+ * Empirically validated against the meshtastic-client KMP SDK probes (Apr 2026): with a 1.5 s pause between
+ * disconnect→reconnect cycles, 3/5–4/5 attempts failed mid-handshake (Stage1Draining timeouts) because the
+ * firmware had not yet released its GATT session from the previous cycle. With ≥ 5 s pause, success rate rose
+ * to 5/5 against a strong (-53 dBm) link. 3 s is a conservative compromise on Android, whose BLE stack is more
+ * mature than btleplug+CoreBluetooth, but the firmware-side cleanup constraint is the same.
+ */
+ val DEFAULT_SETTLE_DELAY = 3.seconds
+ val DEFAULT_MIN_STABLE_CONNECTION = 5.seconds
+
+ internal val RECONNECT_BASE_DELAY = 5.seconds
+ internal val RECONNECT_MAX_DELAY = 60.seconds
+ internal const val BACKOFF_MAX_EXPONENT = 4
+ }
+}
+
+/**
+ * Returns the reconnect backoff delay for a given consecutive failure count.
+ *
+ * Backoff schedule: 1 failure → 5 s, 2 failures → 10 s, 3 failures → 20 s, 4 failures → 40 s, 5+ failures → 60 s
+ * (capped).
+ */
+internal fun computeReconnectBackoff(consecutiveFailures: Int): Duration {
+ if (consecutiveFailures <= 0) return BleReconnectPolicy.RECONNECT_BASE_DELAY
+ val multiplier = 1 shl (consecutiveFailures - 1).coerceAtMost(BleReconnectPolicy.BACKOFF_MAX_EXPONENT)
+ return minOf(BleReconnectPolicy.RECONNECT_BASE_DELAY * multiplier, BleReconnectPolicy.RECONNECT_MAX_DELAY)
+}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt
deleted file mode 100644
index 5354f5500..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactorySpi.kt
+++ /dev/null
@@ -1,30 +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.core.network.radio
-
-import org.meshtastic.core.repository.RadioTransport
-
-/**
- * Radio interface factory service provider interface. Each radio backend implementation needs to have a factory to
- * create new instances. These instances are specific to a particular address. This interface defines a common API
- * across all radio interfaces for obtaining implementation instances.
- *
- * This is primarily used in conjunction with Dagger assisted injection for each backend interface type.
- */
-interface InterfaceFactorySpi {
- fun create(rest: String): T
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt
deleted file mode 100644
index aec9ec667..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/InterfaceSpec.kt
+++ /dev/null
@@ -1,28 +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.core.network.radio
-
-import org.meshtastic.core.repository.RadioInterfaceService
-import org.meshtastic.core.repository.RadioTransport
-
-/** This interface defines the contract that all radio backend implementations must adhere to. */
-interface InterfaceSpec {
- fun createInterface(rest: String, service: RadioInterfaceService): T
-
- /** Return true if this address is still acceptable. For BLE that means, still bonded */
- fun addressValid(rest: String): Boolean = true
-}
diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt
deleted file mode 100644
index 492b5782c..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/MockInterfaceFactory.kt
+++ /dev/null
@@ -1,26 +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