diff --git a/.copilotignore b/.copilotignore
deleted file mode 100644
index 02ec3ad1d..000000000
--- a/.copilotignore
+++ /dev/null
@@ -1,27 +0,0 @@
-# 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
deleted file mode 100644
index 5e535b215..000000000
--- a/.gemini/settings.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "context": {
- "fileName": ["AGENTS.md", "GEMINI.md"]
- }
-}
diff --git a/.github/actions/gradle-setup/action.yml b/.github/actions/gradle-setup/action.yml
index a42959190..3753210b8 100644
--- a/.github/actions/gradle-setup/action.yml
+++ b/.github/actions/gradle-setup/action.yml
@@ -27,14 +27,19 @@ 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
- gradle-home-cache-includes: |
- caches
- notifications
- ~/.m2/repository/org/robolectric
\ No newline at end of file
+ add-job-summary: always
\ No newline at end of file
diff --git a/.github/copilot-commit-message-instructions.md b/.github/copilot-commit-message-instructions.md
deleted file mode 100644
index 93c242d16..000000000
--- a/.github/copilot-commit-message-instructions.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# 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 e856cbe8f..2e60f3dff 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1,6 +1,6 @@
-# Meshtastic Android - GitHub Copilot Guide
+# Meshtastic Android - Agent Guide
-> **Note:** The canonical instructions for all AI Agents have been deduplicated.
+**Canonical instructions live in [`AGENTS.md`](../AGENTS.md).** This file exists at `.github/copilot-instructions.md` so GitHub Copilot discovers it automatically.
-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.
+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.
diff --git a/.github/copilot-pull-request-instructions.md b/.github/copilot-pull-request-instructions.md
deleted file mode 100644
index 8e79d63d2..000000000
--- a/.github/copilot-pull-request-instructions.md
+++ /dev/null
@@ -1,18 +0,0 @@
-# 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
deleted file mode 100644
index 6179bc61a..000000000
--- a/.github/instructions/android-source-set.instructions.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-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
deleted file mode 100644
index d61fa34b8..000000000
--- a/.github/instructions/build-logic.instructions.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-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
deleted file mode 100644
index 55a72b328..000000000
--- a/.github/instructions/ci-workflows.instructions.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-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
deleted file mode 100644
index 7dac915bc..000000000
--- a/.github/instructions/kmp-common.instructions.md
+++ /dev/null
@@ -1,20 +0,0 @@
----
-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
deleted file mode 100644
index 983ecf785..000000000
--- a/.github/lsp.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "lspServers": {
- "kotlin": {
- "command": "kotlin-language-server",
- "args": [],
- "fileExtensions": {
- ".kt": "kotlin",
- ".kts": "kotlin"
- }
- }
- }
-}
diff --git a/.github/renovate.json b/.github/renovate.json
index 1faa1a4ad..c9993abac 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -49,31 +49,236 @@
"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 CMP and the androidx.compose artifacts that track it so Renovate bumps them together (see PR #5180)",
- "groupName": "compose-multiplatform",
+ "description": "Group all AndroidX dependencies (excluding more specific AndroidX groups)",
+ "groupName": "AndroidX (General)",
+ "groupSlug": "androidx-general",
"matchPackageNames": [
- "/^org\\.jetbrains\\.compose/",
- "androidx.compose.runtime:runtime-tracing",
- "androidx.compose.ui:ui-test-manifest"
+ "/^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/"
]
},
{
- "description": "Restrict sensitive infrastructure to manual minor updates",
+ "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)",
"matchUpdateTypes": [
"minor"
],
"matchPackageNames": [
"/^org\\.jetbrains\\.kotlin/",
"/^org\\.jetbrains\\.kotlinx/",
- "/^org\\.jetbrains\\.compose/",
"/^com\\.google\\.dagger/",
"/^androidx\\.hilt/",
"/^com\\.google\\.protobuf/",
@@ -93,4 +298,4 @@
"automerge": false
}
]
-}
+}
\ No newline at end of file
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 000000000..e67a217c7
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,108 @@
+# 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 f7c8151c7..568da41f4 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -6,16 +6,6 @@ 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:
@@ -39,11 +29,11 @@ permissions:
pages: write
id-token: write
-# Allow only one concurrent deployment; cancel queued runs since only the latest
-# main state matters for documentation.
+# 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.
concurrency:
group: "pages"
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
build-docs:
@@ -66,7 +56,7 @@ jobs:
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
- uses: actions/upload-pages-artifact@v5
+ uses: actions/upload-pages-artifact@v4
with:
path: build/dokka/html
diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml
index eaf3f54d3..4c29847a3 100644
--- a/.github/workflows/main-check.yml
+++ b/.github/workflows/main-check.yml
@@ -20,7 +20,8 @@ jobs:
uses: ./.github/workflows/reusable-check.yml
with:
run_lint: true
- run_unit_tests: false
- run_desktop_builds: false
+ run_unit_tests: true
+ run_instrumented_tests: true
+ api_levels: '[35]' # One API level is enough for post-merge sanity check
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml
index 44d31183d..2818ca939 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -18,6 +18,8 @@ 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 c2a1aaf25..2cfe6b15e 100644
--- a/.github/workflows/models_pr_triage.yml
+++ b/.github/workflows/models_pr_triage.yml
@@ -44,16 +44,13 @@ 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: ${{ env.PR_TITLE }}
- Body: ${{ env.PR_BODY }}
+ Title: ${{ github.event.pull_request.title }}
+ Body: ${{ github.event.pull_request.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.
@@ -97,9 +94,6 @@ 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: |
@@ -111,8 +105,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: ${{ env.PR_TITLE }}
- Body: ${{ env.PR_BODY }}
+ Title: ${{ github.event.pull_request.title }}
+ Body: ${{ github.event.pull_request.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 df16866f3..2338a6aeb 100644
--- a/.github/workflows/promote.yml
+++ b/.github/workflows/promote.yml
@@ -139,7 +139,6 @@ 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 d450711ce..6649dbc84 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -3,6 +3,10 @@ name: Pull Request CI
on:
pull_request:
branches: [ main ]
+ paths-ignore:
+ - '**/*.md'
+ - 'docs/**'
+ - '.gitignore'
permissions:
contents: read
@@ -35,6 +39,7 @@ jobs:
- 'desktop/**'
- 'core/**'
- 'feature/**'
+ - 'mesh_service_example/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
@@ -94,9 +99,7 @@ jobs:
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
- # 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.
+ # We disable instrumented tests and coverage for PRs to keep feedback fast (< 10 mins).
validate-and-build:
needs: check-changes
if: needs.check-changes.outputs.android == 'true'
@@ -104,8 +107,9 @@ jobs:
with:
run_lint: true
run_unit_tests: true
+ run_instrumented_tests: false
run_coverage: false
- run_desktop_builds: false
+ api_levels: '[35]'
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 40d8e40f3..77687a105 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@v3
+ uses: softprops/action-gh-release@v2
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@v3
+ uses: softprops/action-gh-release@v2
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 632bf1ea4..75557fe00 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -9,12 +9,15 @@ on:
run_unit_tests:
type: boolean
default: true
+ run_instrumented_tests:
+ type: boolean
+ default: true
run_coverage:
type: boolean
default: true
- run_desktop_builds:
- type: boolean
- default: true
+ api_levels:
+ type: string
+ default: '[35]'
upload_artifacts:
type: boolean
default: true
@@ -94,7 +97,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 kmpSmokeCompile -Pci=true --continue --scan
+ 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
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false
@@ -173,12 +176,14 @@ 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
@@ -213,7 +218,7 @@ jobs:
files: "**/build/test-results/**/*.xml"
- name: Upload coverage to Codecov
- if: ${{ !cancelled() && inputs.run_coverage }}
+ if: ${{ !cancelled() }}
uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
@@ -232,8 +237,130 @@ jobs:
**/build/test-results
retention-days: 7
- # ── Android Build ────────────────────────────────────────────────────
+ # ── Android Build & Instrumented Tests ──────────────────────────────
android-check:
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ timeout-minutes: 60
+ 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
+ uses: actions/checkout@v6
+ with:
+ fetch-depth: 1
+ submodules: true
+
+ - name: Gradle Setup
+ uses: ./.github/actions/gradle-setup
+ with:
+ gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
+ cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
+
+ - name: 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: Upload debug artifact
+ if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-debug-apks
+ path: app/build/outputs/apk/*/debug/*.apk
+ retention-days: 14
+
+ - name: Report App Size
+ if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
+ 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
permissions:
contents: read
@@ -242,54 +369,6 @@ jobs:
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
- steps:
- - name: Checkout code
- uses: actions/checkout@v6
- with:
- fetch-depth: 1
- submodules: true
-
- - name: Gradle Setup
- uses: ./.github/actions/gradle-setup
- with:
- gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
-
- - name: Build Android APKs
- run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
-
- - name: Upload debug artifact
- if: ${{ inputs.upload_artifacts }}
- uses: actions/upload-artifact@v7
- with:
- name: app-debug-apks
- path: app/build/outputs/apk/*/debug/*.apk
- retention-days: 7
-
- - name: Report App Size
- 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
-
- # ── Desktop Build ───────────────────────────────────────────────────
- build-desktop:
- name: Build Desktop Debug (${{ matrix.os }})
- if: inputs.run_desktop_builds == true
- runs-on: ${{ matrix.os }}
- permissions:
- contents: read
- timeout-minutes: 60
- needs: lint-check
- strategy:
- fail-fast: false
- matrix:
- os: [macos-latest, windows-latest, ubuntu-24.04, ubuntu-24.04-arm]
- env:
- VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
-
steps:
- name: Checkout code
uses: actions/checkout@v6
@@ -304,12 +383,12 @@ jobs:
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Desktop
- run: ./gradlew :desktop:createDistributable -Pci=true --scan
+ run: ./gradlew :desktop:packageDistributionForCurrentOS -Pci=true --scan
- name: Upload Desktop artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
- name: desktop-app-${{ runner.os }}-${{ runner.arch }}
- path: desktop/build/compose/binaries/main/app/
+ name: desktop-app
+ path: desktop/build/compose/binaries/main/app/Meshtastic/bin/*
retention-days: 7
diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml
index 2399d1f88..d516537e0 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 */4 * * *' # Run every 4 hours (was hourly — reduced to cut cascade CI cost)
- workflow_dispatch: # Allow manual triggering
+ - cron: '0 * * * *' # Run every hour
+ workflow_dispatch: # Allow manual triggering
jobs:
update_assets:
diff --git a/.gitignore b/.gitignore
index 447d8a28e..97dbb7b24 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,3 @@ 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
deleted file mode 100644
index d0a809449..000000000
--- a/.pr5167.diff
+++ /dev/null
@@ -1,295 +0,0 @@
-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
deleted file mode 100644
index acab253d5..000000000
--- a/.skills/code-review/SKILL.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# 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
deleted file mode 100644
index 22fe1b489..000000000
--- a/.skills/compose-ui/SKILL.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 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
deleted file mode 100644
index 0277bee10..000000000
--- a/.skills/implement-feature/SKILL.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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
deleted file mode 100644
index 46602c430..000000000
--- a/.skills/kmp-architecture/SKILL.md
+++ /dev/null
@@ -1,61 +0,0 @@
-# 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
deleted file mode 100644
index c9d7336a6..000000000
--- a/.skills/navigation-and-di/SKILL.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# 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
deleted file mode 100644
index d63f3f4c2..000000000
--- a/.skills/new-branch/SKILL.md
+++ /dev/null
@@ -1,79 +0,0 @@
-# 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
deleted file mode 100644
index 2224fa7ad..000000000
--- a/.skills/project-overview/SKILL.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# 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
deleted file mode 100644
index 1c8b7b901..000000000
--- a/.skills/testing-ci/SKILL.md
+++ /dev/null
@@ -1,85 +0,0 @@
-# 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 c1bafdd96..ed603d08a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,108 +1,208 @@
-# Meshtastic Android - Unified Agent & Developer Guide
+# Meshtastic Android - Agent Guide
-
-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.
-
+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.
-
-- **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.
-
+For execution-focused recipes, see `docs/agent-playbooks/README.md`.
-
-- **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).
-
+## 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.
-
-- **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.
-
+- **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.
-
-`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.
+## 2. Codebase Map
-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.
-
+| 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. |
-
-- **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.
-
+## 3. Development Guidelines & Coding Standards
-
-These tips apply when the agent is the GitHub Copilot CLI. Other agent runtimes may ignore this
-section.
+### 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`.
-- **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.
-
+### 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.
-
-- **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.
-
+### 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
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index eb5cd5e5c..000000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# 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 72a350afb..9076b718e 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1,6 +1,6 @@
-# Meshtastic Android - Google Gemini Guide
+# Meshtastic Android - Agent Guide
-> **Note:** The canonical instructions for all AI Agents have been deduplicated.
+**Canonical instructions live in [`AGENTS.md`](AGENTS.md).** This file exists as `GEMINI.md` so Google Gemini discovers it automatically.
-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.
+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.
diff --git a/Gemfile.lock b/Gemfile.lock
index cf6a1b9c0..de497cc4a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -3,13 +3,13 @@ GEM
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
- addressable (2.9.0)
+ addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
- aws-partitions (1.1240.0)
- aws-sdk-core (3.245.0)
+ aws-partitions (1.1213.0)
+ aws-sdk-core (3.242.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.123.0)
- aws-sdk-core (~> 3, >= 3.244.0)
+ aws-sdk-kms (1.121.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
aws-sigv4 (~> 1.5)
- aws-sdk-s3 (1.219.0)
- aws-sdk-core (~> 3, >= 3.244.0)
+ aws-sdk-s3 (1.213.0)
+ aws-sdk-core (~> 3, >= 3.241.4)
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.1.2)
+ bigdecimal (4.0.1)
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.4)
+ faraday-retry (1.0.3)
faraday_middleware (1.2.1)
faraday (~> 1.0)
- fastimage (2.4.1)
- fastlane (2.233.0)
+ fastimage (2.4.0)
+ fastlane (2.232.2)
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.1.0)
+ fastlane-sirp (>= 1.0.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
@@ -122,9 +122,10 @@ GEM
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
- fastlane-sirp (1.1.0)
+ fastlane-sirp (1.0.0)
+ sysrandom (~> 1.0)
gh_inspector (1.1.3)
- google-apis-androidpublisher_v3 (0.99.0)
+ google-apis-androidpublisher_v3 (0.95.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
@@ -138,15 +139,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.61.0)
+ google-apis-storage_v1 (0.59.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.6.0)
- google-cloud-storage (1.59.0)
+ google-cloud-errors (1.5.0)
+ google-cloud-storage (1.58.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
@@ -168,13 +169,13 @@ GEM
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
- json (2.19.4)
+ json (2.18.1)
jwt (2.10.2)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
- multi_json (1.20.1)
+ multi_json (1.19.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -184,13 +185,13 @@ GEM
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
- public_suffix (7.0.5)
- rake (13.4.2)
+ public_suffix (7.0.2)
+ rake (13.3.1)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
- retriable (3.4.1)
+ retriable (3.1.2)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
@@ -204,6 +205,7 @@ 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
new file mode 100644
index 000000000..793387334
--- /dev/null
+++ b/SOUL.md
@@ -0,0 +1,31 @@
+# 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 d239d0530..77302534e 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -171,6 +171,8 @@ configure {
} else {
signingConfig = signingConfigs.getByName("debug")
}
+ isMinifyEnabled = true
+ isShrinkResources = true
isDebuggable = false
}
}
@@ -241,10 +243,9 @@ dependencies {
implementation(libs.jetbrains.compose.material3.adaptive.layout)
implementation(libs.jetbrains.compose.material3.adaptive.navigation)
implementation(libs.material)
- 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.compose.material3)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.glance.appwidget.preview)
implementation(libs.androidx.glance.material3)
@@ -264,6 +265,7 @@ 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)
@@ -279,6 +281,7 @@ 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)
@@ -294,6 +297,12 @@ 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)
@@ -301,7 +310,7 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.test.core)
- testImplementation(libs.compose.multiplatform.ui.test)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.glance.appwidget)
}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index de2b3144c..995f659ba 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,45 +1,61 @@
-# ============================================================================
-# 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.
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.kts.
#
-# 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.
-# ============================================================================
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
-# ---- General ----------------------------------------------------------------
+# 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 *;
+#}
-# Open-source — no need to obfuscate
--dontobfuscate
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+-keepattributes SourceFile,LineNumberTable
-# 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
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
-# 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
+# Room KMP: preserve generated database constructor (required for R8/ProGuard)
+-keep class * extends androidx.room.RoomDatabase { (); }
-# ---- Networking (transitive references from Ktor on Android) ----------------
+# Needed for protobufs
+-keep class com.google.protobuf.** { *; }
+-keep class org.meshtastic.proto.** { *; }
+# Networking
-dontwarn org.conscrypt.**
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
-# 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.
+# ?
+-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.** { *; }
diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
new file mode 100644
index 000000000..4cbf88356
--- /dev/null
+++ b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 b4d0e1bbd..54935b422 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapView.kt
@@ -77,6 +77,8 @@ 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
@@ -128,8 +130,6 @@ 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,9 +861,15 @@ private fun CacheInfoDialog(mapView: MapView, onDismiss: () -> Unit) {
onDismiss = onDismiss,
negativeButton = { TextButton(onClick = { onDismiss() }) { Text(text = stringResource(Res.string.close)) } },
) {
- 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))
+ Text(
+ modifier = Modifier.padding(16.dp),
+ text =
+ stringResource(
+ Res.string.map_cache_info,
+ cacheCapacity / (1024.0 * 1024.0),
+ currentCacheUsage / (1024.0 * 1024.0),
+ ),
+ )
}
}
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 3cc0dbaf0..04f896d18 100644
--- a/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
+++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/MapViewExtensions.kt
@@ -124,21 +124,20 @@ fun MapView.addPolyline(density: Density, geoPoints: List, onClick: ()
return polyline
}
-fun MapView.addPositionMarkers(positions: List, onClick: (Int) -> Unit): List {
+fun MapView.addPositionMarkers(positions: List, onClick: () -> Unit): List {
val navIcon = ContextCompat.getDrawable(context, R.drawable.ic_map_navigation)
- 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
- }
+ 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
}
}
+ }
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 77b595d88..0178a498e 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,17 +26,9 @@ import org.meshtastic.proto.Position
* Flavor-unified entry point for the embeddable node-track map. Resolves [destNum] to obtain
* [NodeMapViewModel.applicationId] and [NodeMapViewModel.mapStyleId], then delegates to the OSMDroid implementation
* ([NodeTrackOsmMap]).
- *
- * Supports optional synchronized selection via [selectedPositionTime] and [onPositionSelected].
*/
@Composable
-fun NodeTrackMap(
- destNum: Int,
- positions: List,
- modifier: Modifier = Modifier,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
+fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) {
val vm = koinViewModel()
vm.setDestNum(destNum)
NodeTrackOsmMap(
@@ -44,7 +36,5 @@ fun NodeTrackMap(
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 a6aec4c2d..64d207a6e 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,6 +42,7 @@ 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
@@ -49,7 +50,6 @@ 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,10 +61,8 @@ 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.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].
+ * 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.
*
* 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.
@@ -75,8 +73,6 @@ fun NodeTrackOsmMap(
applicationId: String,
mapStyleId: Int,
modifier: Modifier = Modifier,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
mapViewModel: MapViewModel = koinViewModel(),
) {
val density = LocalDensity.current
@@ -113,15 +109,7 @@ fun NodeTrackOsmMap(
map.addCopyright()
map.addScaleBarOverlay(density)
map.addPolyline(density, geoPoints) {}
- map.addPositionMarkers(filteredPositions) { time -> onPositionSelected?.invoke(time) }
- // Center on selected position
- if (selectedPositionTime != null) {
- val selected = filteredPositions.find { it.time == selectedPositionTime }
- if (selected != null) {
- val point = GeoPoint((selected.latitude_i ?: 0) * DEG_D, (selected.longitude_i ?: 0) * DEG_D)
- map.controller.animateTo(point)
- }
- }
+ map.addPositionMarkers(filteredPositions) {}
},
)
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 0583dd78e..bf42494e5 100644
--- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt
@@ -26,6 +26,7 @@ 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
@@ -159,6 +160,7 @@ 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 c8f2f3fee..0418d76b7 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -33,7 +33,6 @@ 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
@@ -97,6 +96,8 @@ 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
@@ -135,8 +136,6 @@ 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
@@ -156,12 +155,7 @@ 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,
- val selectedPositionTime: Int? = null,
- val onPositionSelected: ((Int) -> Unit)? = null,
- ) : GoogleMapMode
+ data class NodeTrack(val focusedNode: Node?, val positions: List) : GoogleMapMode
/** Traceroute visualization: offset forward/return polylines + hop markers. */
data class Traceroute(
@@ -430,17 +424,6 @@ 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) {
@@ -594,8 +577,6 @@ fun MapView(
sortedPositions = sortedTrackPositions,
displayUnits = displayUnits,
myNodeNum = myNodeNum,
- selectedPositionTime = mode.selectedPositionTime,
- onPositionSelected = mode.onPositionSelected,
)
}
}
@@ -827,24 +808,17 @@ 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) {
@@ -855,23 +829,13 @@ private fun NodeTrackOverlay(
} else {
1f
}
- val isSelected = position.time == selectedPositionTime
- val color =
- if (isSelected) {
- selectedColor
- } else {
- Color(focusedNode.colors.second).copy(alpha = alpha)
- }
+ val color = 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)
}
@@ -880,18 +844,13 @@ private fun NodeTrackOverlay(
state = markerState,
title = stringResource(Res.string.position),
snippet = formatAgo(position.time),
- zIndex = if (isSelected) activeNodeZIndex - 0.5f else 1f + alpha,
- onClick = {
- onPositionSelected?.invoke(position.time)
- false // Allow default info window behavior
- },
+ zIndex = 1f + alpha,
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 e4eabbb76..70ff4858d 100644
--- a/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
+++ b/app/src/google/kotlin/org/meshtastic/app/map/MapViewModel.kt
@@ -28,11 +28,7 @@ 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 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.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -49,7 +45,6 @@ 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
@@ -82,8 +77,6 @@ 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,
@@ -411,7 +404,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) {
@@ -419,33 +412,32 @@ 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
- }
-
- layerType?.let {
- val uri = Uri.fromFile(file)
- MapLayerItem(
- name = file.nameWithoutExtension,
- uri = uri,
- isVisible = !hiddenLayerUrls.contains(uri.toString()),
- layerType = it,
- )
+ val loadedItems = persistedLayerFiles.mapNotNull { file ->
+ if (file.isFile) {
+ val layerType =
+ when (file.extension.lowercase()) {
+ "kml",
+ "kmz",
+ -> LayerType.KML
+ "geojson",
+ "json",
+ -> LayerType.GEOJSON
+ else -> null
}
- } else {
- null
+
+ layerType?.let {
+ val uri = Uri.fromFile(file)
+ MapLayerItem(
+ name = file.nameWithoutExtension,
+ uri = uri,
+ isVisible = !hiddenLayerUrls.contains(uri.toString()),
+ layerType = it,
+ )
}
+ } else {
+ null
}
+ }
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@@ -558,7 +550,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")
@@ -629,7 +621,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
- withContext(dispatchers.io) {
+ withContext(Dispatchers.IO) {
try {
val file = uri.toFile()
if (file.exists()) {
@@ -644,15 +636,11 @@ 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 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()
+ val url = java.net.URL(uriToLoad.toString())
+ java.io.BufferedInputStream(url.openStream())
} 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 fd9272579..fb5f682ed 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,7 +31,6 @@ 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
@@ -126,10 +125,7 @@ fun CustomMapLayersSheet(
}
}
}
- IconToggleButton(
- checked = layer.isVisible,
- onCheckedChange = { onToggleVisibility(layer.id) },
- ) {
+ IconButton(onClick = { 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 2f7244b97..513957c61 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,28 +31,11 @@ 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,
- selectedPositionTime: Int? = null,
- onPositionSelected: ((Int) -> Unit)? = null,
-) {
+fun NodeTrackMap(destNum: Int, positions: List, modifier: Modifier = Modifier) {
val vm = koinViewModel()
vm.setDestNum(destNum)
val focusedNode by vm.node.collectAsStateWithLifecycle()
- MapView(
- modifier = modifier,
- mode =
- GoogleMapMode.NodeTrack(
- focusedNode = focusedNode,
- positions = positions,
- selectedPositionTime = selectedPositionTime,
- onPositionSelected = onPositionSelected,
- ),
- )
+ MapView(modifier = modifier, mode = GoogleMapMode.NodeTrack(focusedNode = focusedNode, positions = positions))
}
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 668dedbaa..e33fb1f8c 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,10 +36,9 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
- 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") },
- )
+ 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") },
+ )
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f7d2ce900..43468c69d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -288,7 +288,7 @@
+ android:resource="@xml/local_stats_widget_info" />
diff --git a/app/src/main/assets/firmware_releases.json b/app/src/main/assets/firmware_releases.json
index ffdb465d6..4d74c2b5a 100644
--- a/app/src/main/assets/firmware_releases.json
+++ b/app/src/main/assets/firmware_releases.json
@@ -24,19 +24,12 @@
}
],
"alpha": [
- {
- "id": "v2.7.22.96dd647",
- "title": "Meshtastic Firmware 2.7.22.96dd647 Alpha",
- "page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.22.96dd647",
- "zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.22.96dd647/firmware-2.7.22.96dd647.json",
- "release_notes": "## 🐛 Bug fixes and maintenance\r\n\r\n- Fix(native): implement BinarySemaphorePosix with proper pthread synchronization by @iannucci in https://github.com/meshtastic/firmware/pull/9895\r\n- Meshtasticd: Add configs for ebyte-ecb41-pge (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10086\r\n- Meshtasticd: Add configs for forlinx-ok3506-s12 (mPWRD-OS) by @vidplace7 in https://github.com/meshtastic/firmware/pull/10087\r\n- Fix Linux Input enable logic by @jp-bennett in https://github.com/meshtastic/firmware/pull/10093\r\n- PPA: Use SFTP method for uploads by @vidplace7 in https://github.com/meshtastic/firmware/pull/10138\r\n- Switch PlatformIO deps from PIO Registry to tagged GitHub zips by @vidplace7 in https://github.com/meshtastic/firmware/pull/10142\r\n- Fix display method to use const qualifier for previousBuffer pointer by @vidplace7 in https://github.com/meshtastic/firmware/pull/10146\r\n- Fix last cppcheck issue by @caveman99 in https://github.com/meshtastic/firmware/pull/10154\r\n- Fix heap blowout on TBeams by @thebentern in https://github.com/meshtastic/firmware/pull/10155\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update meshtastic-esp32_https_server digest to 0c71f38 by @app/renovate in https://github.com/meshtastic/firmware/pull/10081\r\n- Update meshtastic-st7789 digest to 222554e by @app/renovate in https://github.com/meshtastic/firmware/pull/10121\r\n- Update actions/github-script action to v9 by @app/renovate in https://github.com/meshtastic/firmware/pull/10122\r\n- Update meshtastic-st7789 digest to 7228c49 by @app/renovate in https://github.com/meshtastic/firmware/pull/10131\r\n- Update pnpm/action-setup action to v6 by @app/renovate in https://github.com/meshtastic/firmware/pull/10132\r\n- Update meshtastic-st7789 digest to 4d957e7 by @app/renovate in https://github.com/meshtastic/firmware/pull/10134\r\n- Update meshtastic-st7789 digest to a787bee by @app/renovate in https://github.com/meshtastic/firmware/pull/10147\r\n- Update softprops/action-gh-release action to v3 by @app/renovate in https://github.com/meshtastic/firmware/pull/10150\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.21.1370b23...v2.7.22.96dd647"
- },
{
"id": "v2.7.21.1370b23",
"title": "Meshtastic Firmware 2.7.21.1370b23 Alpha",
"page_url": "https://github.com/meshtastic/firmware/releases/tag/v2.7.21.1370b23",
"zip_url": "https://github.com/meshtastic/firmware/releases/download/v2.7.21.1370b23/firmware-2.7.21.1370b23.json",
- "release_notes": "> [!WARNING]\r\n> Due to resource constraints, the HTTP server is deprecated on original-generation ESP32 devices and should not be relied on going forward. \r\n> Support continues on ESP32-S3 and other newer ESP32 generations.\r\n\r\n## 🚀 Enhancements\r\n\r\n- Add T5-4.7-S3 Epaper Pro support. #6625\r\n- Apply Thailand NBTC 920-925 MHz limits (27 dBm, 10% duty cycle). #9827\r\n- Switch nRF52840 builds to C++17. #9874\r\n- Clean up SEN5X warnings. #9884\r\n- Refactor BaseUI emotes. #9896\r\n- Add spoof detection in `UdpMulticastHandler`. #9905\r\n- Enable LNA by default on Heltec v4.3. #9906\r\n- Rotate MUI for the Heltec V4 + TFT expansion kit. #9938\r\n- Make `hexDump()` take a `const` buffer. #9944\r\n- Add `meshtasticd` config metadata. #10001\r\n- Add `MESHTASTIC_EXCLUDE_ACCELEROMETER`. #10004\r\n- Adapt MUI WiFi map tile downloads for Heltec V4. #10011\r\n- Fix Mesh-tab WiFi map and exclude-screen behavior. #10038\r\n- Include Thinknode M5 minor fixes. #10049\r\n\r\n## 🐛 Bug fixes and maintenance\r\n\r\n- Remove GPS baudrate locking on the Seeed Xiao S3 kit. #9374\r\n- Fix RAK4631 Ethernet gateway API connection loss after W5100S brownouts. #9754\r\n- Fix W5100S socket exhaustion blocking MQTT and additional TCP clients. #9770\r\n- Fix traceroute over MQTT when the uplink node is encrypted. #9798\r\n- Extend Debian sourcedeb cache expiration. #9858\r\n- Fix T-LoRA Pager SPI bus sharing between SX1262 and the SD card. #9870\r\n- Update `ESP8266Audio` to the Meshtastic fork for compatibility. #9872\r\n- Fix `rak_wismeshtag` low-voltage reboot hangs after app configuration. #9897\r\n- Preserve `pki_encrypted` and `public_key` when relaying UDP multicast packets to radio. #9916\r\n- Add the new RAK 13302 power curve. #9929\r\n- Fix MQTT settings not persisting when the broker is unreachable. #9934\r\n- Fix BMP detection by not returning early during BME address scans. #9935\r\n- Enforce infrastructure-role minimums even when scaling is disabled. #9937\r\n- Fix traceroute hop rendering for `ffff` / unknown-dB hops. #9945\r\n- Fix NodeInfo suppression so it only applies to external requests. #9947\r\n- Enable touch-to-backlight on T-Echo, not just T-Echo Plus. #9953\r\n- Prevent licensed users from rebroadcasting packets to or from unlicensed users. #9958\r\n- Add the `heltec_mesh_node_t096` board. #9960\r\n- Add Cardputer-Adv I2S audio support. #9963\r\n- Fix the Cyrillic OLED double-space issue. #9971\r\n- Add `LED_BUILTIN` for `tlora_v1`. #9973\r\n- Add a timeout for PPA uploads. #9989\r\n- Exclude the web server, Paxcounter, and a few other components on original ESP32 boards to avoid IRAM overflow. #10005\r\n- Rework External Notifications logic. #10006\r\n- Improve STM32WL support. #10015\r\n- Configure NFC pins as GPIO for older bootloaders. #10016\r\n- Fix `TransmitHistory` epoch handling. #10017\r\n- Inherit `build_unflags` for `wio-sdk-wm1110`. #10034\r\n- Remove PSRAM from `tbeam` boards to reclaim IRAM. #10036\r\n- Move `t5s3_epaper_inkhud` to `extra`. #10037\r\n\r\n## ⚙️ Dependencies\r\n\r\n- Update `meshtastic-esp32_https_server` to digest `b78f12c`. #9851\r\n- Update `meshtastic/device-ui` through digests `622b034`, `f36d2a9`, `7b1485b`, and `1897dd1`. #9864 #9940 #10023 #10044 #10050\r\n- Update `GxEPD2` to `v1.6.8`. #9918\r\n- Update `pnpm/action-setup` to `v5`. #9926\r\n- Update `dorny/test-reporter` to `v3`. #9981\r\n- Clean up LewisHe library references and dependency matching, and tighten Renovate scheduling. #10007 #10008 #10039\r\n- Update `Adafruit_BME680` to `v2.0.6`. #10009\r\n\r\n**Full Changelog**: https://github.com/meshtastic/firmware/compare/v2.7.20.6658ec2...v2.7.21.1370b23\r\n"
+ "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"
},
{
"id": "v2.7.20.6658ec2",
@@ -184,8 +177,22 @@
"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": []
+ "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"
+ }
+ ]
}
\ 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 628865010..342b845dd 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt
@@ -45,12 +45,11 @@ 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
@@ -58,8 +57,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
@@ -92,8 +91,6 @@ 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.
@@ -127,8 +124,6 @@ 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) {
@@ -146,7 +141,7 @@ class MainActivity : ComponentActivity() {
}
AppCompositionLocals {
- AppTheme(dynamicColor = dynamic, darkTheme = dark, contrastLevel = contrastLevel) {
+ AppTheme(dynamicColor = dynamic, darkTheme = dark) {
val appIntroCompleted by model.appIntroCompleted.collectAsStateWithLifecycle()
// Signal to the system that the initial UI is "fully drawn"
@@ -169,16 +164,6 @@ 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(
@@ -190,14 +175,8 @@ class MainActivity : ComponentActivity() {
LocalMapViewProvider provides getMapViewProvider(),
LocalInlineMapProvider provides { node, modifier -> InlineMap(node, modifier) },
LocalNodeTrackMapProvider provides
- { destNum, positions, modifier, selectedPositionTime, onPositionSelected ->
- org.meshtastic.app.map.node.NodeTrackMap(
- destNum,
- positions,
- modifier,
- selectedPositionTime,
- onPositionSelected,
- )
+ { destNum, positions, modifier ->
+ org.meshtastic.app.map.node.NodeTrackMap(destNum, positions, modifier)
},
LocalTracerouteMapOverlayInsetsProvider provides getTracerouteMapOverlayInsets(),
LocalTracerouteMapProvider provides
@@ -270,11 +249,6 @@ 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()
}
@@ -296,7 +270,7 @@ class MainActivity : ComponentActivity() {
private fun handleMeshtasticUri(uri: Uri) {
Logger.d { "Handling Meshtastic URI: $uri" }
- model.handleDeepLink(uri.toKmpUri()) { lifecycleScope.launch { showToast(Res.string.channel_invalid) } }
+ model.handleDeepLink(uri.toMeshtasticUri()) { 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 9228b6874..d32cc3df6 100644
--- a/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt
@@ -28,7 +28,6 @@ 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
@@ -37,8 +36,9 @@ 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.plugin.module.dsl.startKoin
-import org.meshtastic.app.di.AndroidKoinApp
+import org.koin.core.context.startKoin
+import org.meshtastic.app.di.AppKoinModule
+import org.meshtastic.app.di.module
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.repository.MeshPrefs
@@ -57,15 +57,16 @@ open class MeshUtilApplication :
Application(),
Configuration.Provider {
- private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ private val applicationScope = CoroutineScope(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/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
index 91ab81ec0..7f6fb0215 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt
@@ -24,8 +24,6 @@ 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
@@ -33,25 +31,18 @@ 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 {
@@ -72,12 +63,7 @@ class NetworkModule {
buildConfigProvider: BuildConfigProvider,
): ImageLoader = ImageLoader.Builder(context = application)
.components {
- add(
- KtorNetworkFetcherFactory(
- httpClient = httpClient,
- concurrentRequestStrategy = DeDupeConcurrentRequestStrategy(),
- ),
- )
+ add(KtorNetworkFetcherFactory(httpClient = httpClient))
add(SvgDecoder.Factory(scaleToDensity = true))
}
.memoryCache {
@@ -90,7 +76,6 @@ class NetworkModule {
.build()
}
.logger(logger = if (buildConfigProvider.isDebug) DebugLogger(minLevel = Logger.Level.Verbose) else null)
- .memoryCacheMaxSizePercentWhileInBackground(MEMORY_CACHE_BACKGROUND_PERCENT)
.crossfade(enable = true)
.build()
@@ -98,21 +83,8 @@ 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) {
- logger = KermitHttpLogger
- level = LogLevel.BODY
- }
+ install(plugin = Logging) { level = LogLevel.BODY }
}
}
}
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
similarity index 97%
rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt
rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
index a8bce5529..997d7d08b 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapButton.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapButton.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
similarity index 87%
rename from feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
rename to app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
index 431354e6d..74f08e07f 100644
--- a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsOverlay.kt
+++ b/app/src/main/kotlin/org/meshtastic/app/map/component/MapControlsOverlay.kt
@@ -14,15 +14,13 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.map.component
+package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-import androidx.compose.material3.FloatingToolbarDefaults
-import androidx.compose.material3.HorizontalFloatingToolbar
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -43,9 +41,8 @@ import org.meshtastic.core.ui.icon.Tune
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
/**
- * Shared map controls overlay using [HorizontalFloatingToolbar] for Material 3 Expressive styling. Provides compass,
- * filter button, location tracking button, and optional slots for flavor-specific content (map type selector, layers,
- * refresh).
+ * Shared map controls overlay used by both Google and F-Droid map views. Provides compass, filter button, location
+ * tracking button, and optional slots for flavor-specific content (map type selector, layers, refresh).
*
* @param onToggleFilterMenu Callback to open/close the filter dropdown.
* @param filterDropdownContent Composable rendered inside a [Box] alongside the filter button — typically a
@@ -57,7 +54,6 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
* @param isRefreshing Whether a refresh is currently in progress.
* @param onRefresh Callback when the refresh button is clicked.
*/
-@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongParameterList")
@Composable
fun MapControlsOverlay(
@@ -75,11 +71,7 @@ fun MapControlsOverlay(
isRefreshing: Boolean = false,
onRefresh: () -> Unit = {},
) {
- HorizontalFloatingToolbar(
- expanded = true,
- modifier = modifier,
- colors = FloatingToolbarDefaults.standardFloatingToolbarColors(),
- ) {
+ Row(modifier = modifier) {
// Compass
CompassButton(onClick = onCompassClick, bearing = bearing, isFollowing = followPhoneBearing)
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 30e1b6be7..7b140cca8 100644
--- a/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt
@@ -25,7 +25,6 @@ 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
@@ -61,19 +60,4 @@ 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 37c19f477..8f262c47c 100644
--- a/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
+++ b/app/src/test/kotlin/org/meshtastic/app/service/Fakes.kt
@@ -16,6 +16,7 @@
*/
package org.meshtastic.app.service
+import android.app.Notification
import dev.mokkery.MockMode
import dev.mokkery.mock
import org.meshtastic.core.model.Node
@@ -36,7 +37,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 de6062d33..0665d50db 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.ExperimentalTestApi
-import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.test.junit4.v2.createComposeRule
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,14 +35,15 @@ 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() = runComposeUiTest {
- setContent {
+ fun verifyNavigationGraphsAssembleWithoutCrashing() {
+ composeTestRule.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 71823c763..faaeb9f68 100644
--- a/build-logic/convention/build.gradle.kts
+++ b/build-logic/convention/build.gradle.kts
@@ -54,6 +54,7 @@ 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 16166a776..046e3c4aa 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 38cc021a7..fd432a1fa 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-2026 Meshtastic LLC
+ * 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
@@ -14,6 +14,7 @@
* 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
@@ -25,6 +26,7 @@ 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")
@@ -36,8 +38,16 @@ class AndroidApplicationConventionPlugin : Plugin {
extensions.configure {
configureKotlinAndroid(this)
+
+ defaultConfig {
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ vectorDrawables.useSupportLibrary = true
+ }
- defaultConfig { vectorDrawables.useSupportLibrary = true }
+ testOptions {
+ animationsDisabled = true
+ unitTests.isReturnDefaultValues = true
+ }
buildTypes {
getByName("release") {
@@ -45,8 +55,7 @@ class AndroidApplicationConventionPlugin : Plugin {
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
- rootProject.file("config/proguard/shared-rules.pro"),
- "proguard-rules.pro",
+ "proguard-rules.pro"
)
}
getByName("debug") {
@@ -58,7 +67,9 @@ 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 68771d24a..cf3ae81db 100644
--- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt
@@ -38,6 +38,11 @@ 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 be280f29c..4d02a630a 100644
--- a/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt
@@ -42,7 +42,6 @@ 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)
@@ -54,18 +53,19 @@ 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("compose-multiplatform-ui"))
+ implementation(libs.library("androidx-compose-ui-text"))
+ implementation(libs.library("androidx-compose-ui-tooling-preview"))
}
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 67b2c8fd0..2a9504221 100644
--- a/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpLibraryComposeConventionPlugin.kt
@@ -32,12 +32,10 @@ class KmpLibraryComposeConventionPlugin : Plugin {
apply(plugin = libs.plugin("compose-multiplatform").get().pluginId)
extensions.configure {
- 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"))
- }
+ 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"))
}
}
configureComposeCompiler()
diff --git a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
index 540834ef5..a1a111a64 100644
--- a/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KmpLibraryConventionPlugin.kt
@@ -14,9 +14,11 @@
* 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
@@ -37,6 +39,8 @@ 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 b4f2acfbe..9b832ce16 100644
--- a/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/KoinConventionPlugin.kt
@@ -29,12 +29,11 @@ class KoinConventionPlugin : Plugin {
// Configure Koin K2 Compiler Plugin (0.4.0+)
extensions.configure(KoinGradleExtension::class.java) {
- // 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.
+ // 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.
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 b438fe6c6..40cbe83fa 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,46 +24,18 @@ 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 {
- "debugImplementation"(libs.library("compose-multiplatform-ui-tooling"))
- "implementation"(libs.library("compose-multiplatform-runtime"))
+ 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"))
"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 088ca0d25..580db4c4b 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 PUBLISHED_MODULES) JavaVersion.VERSION_17 else JavaVersion.VERSION_21
+ val javaVersion = if (project.name in listOf("api", "model", "proto")) {
+ 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,23 +72,6 @@ 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()
@@ -207,25 +190,11 @@ 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 (isPublishedModule) 17 else 21
+ val javaVersion = if (project.name in listOf("api", "model", "proto")) 17 else 21
+ val isPublishedModule = project.name in listOf("api", "model", "proto")
// 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)
@@ -239,7 +208,14 @@ private inline fun Project.configureKotlin() {
if (!isPublishedModule) {
freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
}
- freeCompilerArgs.addAll(SHARED_COMPILER_ARGS)
+ 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",
+ )
if (isJvmTarget) {
freeCompilerArgs.add("-jvm-default=no-compatibility")
}
@@ -254,13 +230,21 @@ 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(SHARED_COMPILER_ARGS)
- freeCompilerArgs.add("-jvm-default=no-compatibility")
+ 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",
+ )
}
}
}
diff --git a/build-logic/settings.gradle.kts b/build-logic/settings.gradle.kts
index 91b8ebce2..2fa797c74 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.1")
+ id("com.gradle.develocity") version("4.4.0")
}
dependencyResolutionManagement {
diff --git a/codecov.yml b/codecov.yml
index 7f77510ff..6e0989227 100644
--- a/codecov.yml
+++ b/codecov.yml
@@ -57,6 +57,10 @@ 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
new file mode 100644
index 000000000..dfcc793f4
--- /dev/null
+++ b/conductor/code_styleguides/general.md
@@ -0,0 +1,23 @@
+# 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
new file mode 100644
index 000000000..3a362bc99
--- /dev/null
+++ b/conductor/index.md
@@ -0,0 +1,14 @@
+# 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
new file mode 100644
index 000000000..b54944fea
--- /dev/null
+++ b/conductor/product-guidelines.md
@@ -0,0 +1,19 @@
+# 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
new file mode 100644
index 000000000..edfac5083
--- /dev/null
+++ b/conductor/product.md
@@ -0,0 +1,26 @@
+# 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
new file mode 100644
index 000000000..75237887b
--- /dev/null
+++ b/conductor/tech-stack.md
@@ -0,0 +1,38 @@
+# 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
new file mode 100644
index 000000000..0b5c54e3d
--- /dev/null
+++ b/conductor/tracks.md
@@ -0,0 +1,5 @@
+# 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
new file mode 100644
index 000000000..6f9cfd8fc
--- /dev/null
+++ b/conductor/workflow.md
@@ -0,0 +1,333 @@
+# 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 de820bc85..1bb8534cd 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=37
-COMPILE_SDK=37
+TARGET_SDK=36
+COMPILE_SDK=36
# 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
deleted file mode 100644
index 8d0d8efde..000000000
--- a/config/proguard/shared-rules.pro
+++ /dev/null
@@ -1,166 +0,0 @@
-# ============================================================================
-# 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 711cccc09..c2533dd3c 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.compose.multiplatform.material3)
- implementation(libs.compose.multiplatform.runtime)
- implementation(libs.compose.multiplatform.ui)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.runtime)
+ implementation(libs.androidx.compose.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.compose.multiplatform.ui.test)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
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 aa222b7c2..e06562cfb 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,17 +16,21 @@
*/
package org.meshtastic.core.barcode
-import androidx.compose.ui.test.ExperimentalTestApi
-import androidx.compose.ui.test.runComposeUiTest
+import androidx.compose.ui.test.junit4.v2.createComposeRule
+import org.junit.Rule
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 {
- @Test fun testRememberBarcodeScanner() = runComposeUiTest { setContent { rememberBarcodeScanner { _ -> } } }
+ @get:Rule val composeTestRule = createComposeRule()
+
+ @Test
+ fun testRememberBarcodeScanner() {
+ composeTestRule.setContent { rememberBarcodeScanner { _ -> } }
+ }
}
diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts
index f270e6aa3..b61fad0e7 100644
--- a/core/ble/build.gradle.kts
+++ b/core/ble/build.gradle.kts
@@ -46,9 +46,13 @@ kotlin {
implementation(libs.jetbrains.lifecycle.runtime)
}
- commonTest.dependencies {
- implementation(libs.kotlinx.coroutines.test)
- implementation(projects.core.testing)
+ commonTest.dependencies { implementation(libs.kotlinx.coroutines.test) }
+
+ val androidHostTest by getting {
+ dependencies {
+ implementation(libs.junit)
+ implementation(libs.androidx.lifecycle.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 b330453e1..c8d444688 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,7 +31,6 @@ 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
@@ -50,7 +49,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() }
@@ -87,7 +86,7 @@ class AndroidBluetoothRepository(
return
}
- suspendCancellableCoroutine { cont ->
+ kotlinx.coroutines.suspendCancellableCoroutine { cont ->
val receiver =
object : android.content.BroadcastReceiver() {
@SuppressLint("MissingPermission")
@@ -181,15 +180,14 @@ class AndroidBluetoothRepository(
// user renamed the device in firmware since the cache was populated.
deviceCache.keys.retainAll(bondedAddresses)
return bonded.map { device ->
- val cached = deviceCache.getOrPut(device.address) { MeshtasticBleDevice(device.address, device.name) }
- // If the name changed (firmware rename, etc.), replace the cached entry and return the new one.
- if (cached.name != device.name) {
- val updated = MeshtasticBleDevice(device.address, device.name)
- deviceCache[device.address] = updated
- updated
- } else {
- cached
- }
+ 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)
+ }
+ }
}
}
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 b0617635a..e9928f8d5 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,29 +20,15 @@ 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) {
- // 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.
+ // 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.
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 1ea11622d..1bfaff648 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,17 +19,14 @@ package org.meshtastic.core.ble
import com.juul.kable.Peripheral
import kotlin.concurrent.Volatile
-/** Snapshot of the currently active BLE peripheral and its address, updated atomically. */
-internal data class ActiveConnection(val peripheral: Peripheral, val address: String)
-
/**
* A simple global tracker for the currently active BLE connection. This resolves instance mismatch issues between
* dynamically created UI devices (scanned vs bonded) and the actual connection.
*
- * [active] is a single volatile reference so readers always see a consistent peripheral/address pair — the previous
- * two-field design (`activePeripheral` + `activeAddress`) was susceptible to TOCTOU races when fields were updated
- * non-atomically.
+ * Fields are volatile to ensure visibility across AIDL binder threads and coroutine dispatchers.
*/
internal object ActiveBleConnection {
- @Volatile var active: ActiveConnection? = null
+ @Volatile var activePeripheral: Peripheral? = null
+
+ @Volatile var activeAddress: String? = 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 59cf134de..06496aeea 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,7 +19,6 @@ 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
@@ -50,8 +49,8 @@ interface BleConnection {
/** Connects to the given [BleDevice]. */
suspend fun connect(device: BleDevice)
- /** Connects to the given [BleDevice] and waits for a terminal state or [timeout]. */
- suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState
+ /** Connects to the given [BleDevice] and waits for a terminal state. */
+ suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState
/** Disconnects from the current device. */
suspend fun disconnect()
@@ -78,17 +77,6 @@ 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 2026b0cb1..a9f82c5f9 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,53 +17,16 @@
package org.meshtastic.core.ble
/** Represents the state of a BLE connection. */
-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
+sealed class BleConnectionState {
+ /** The peripheral is disconnected. */
+ object Disconnected : BleConnectionState()
/** The peripheral is connecting. */
- data object Connecting : BleConnectionState
+ object Connecting : BleConnectionState()
/** The peripheral is connected. */
- data object Connected : BleConnectionState
+ object Connected : BleConnectionState()
/** The peripheral is disconnecting. */
- 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
+ object Disconnecting : BleConnectionState()
}
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
deleted file mode 100644
index d273a0b90..000000000
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/BleExceptionClassifier.kt
+++ /dev/null
@@ -1,60 +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 .
- */
-@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 5e85a52f8..c636d4718 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,7 +48,9 @@ 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
new file mode 100644
index 000000000..9e32e4602
--- /dev/null
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/DirectBleDevice.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.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 f658d234c..5265127c1 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,11 +18,9 @@ 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
@@ -32,6 +30,7 @@ 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
@@ -40,7 +39,6 @@ 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. */
@@ -52,9 +50,6 @@ 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))
@@ -83,11 +78,8 @@ class KableBleService(private val peripheral: Peripheral, private val serviceUui
/**
* [BleConnection] implementation using Kable for cross-platform BLE communication.
*
- * Manages peripheral lifecycle, connection state tracking, and GATT service profile access.
- *
- * Connection attempts follow Kable's recommended pattern from the SensorTag sample: try a direct connect first, then
- * fall back to `autoConnect = true` on failure. Only two attempts are made per [connect] call — the caller
- * ([BleRadioTransport]) owns the macro-level retry/backoff loop.
+ * Manages peripheral lifecycle (connect with exponential backoff, disconnect, reconnect), connection state tracking,
+ * and GATT service profile access.
*/
class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
@@ -96,8 +88,10 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
private var connectionScope: CoroutineScope? = null
companion object {
- /** Settle delay between a direct connect failure and the autoConnect fallback attempt. */
- private val AUTOCONNECT_FALLBACK_DELAY = 1.seconds
+ private 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
}
private val _deviceFlow = MutableSharedFlow(replay = 1)
@@ -114,32 +108,47 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
)
override val connectionState: SharedFlow = _connectionState.asSharedFlow()
- @Suppress("CyclomaticComplexMethod", "LongMethod")
+ @Suppress("LongMethod", "CyclomaticComplexMethod")
override suspend fun connect(device: BleDevice) {
- 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 autoConnect = MutableStateFlow(device is DirectBleDevice)
val p =
- meshtasticDevice.advertisement?.let { adv -> Peripheral(adv) { commonConfig() } }
- ?: createPeripheral(device.address) { commonConfig() }
+ 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}")
+ }
- cleanUpPeripheral(device.address)
+ // 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" }
+ }
+ }
peripheral = p
- ActiveBleConnection.active = ActiveConnection(p, device.address)
+ ActiveBleConnection.activePeripheral = p
+ ActiveBleConnection.activeAddress = device.address
_deviceFlow.emit(device)
@@ -153,15 +162,21 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
hasStartedConnecting = true
}
- meshtasticDevice.updateState(mappedState)
+ when (device) {
+ is KableBleDevice -> device.updateState(mappedState)
+ is DirectBleDevice -> device.updateState(mappedState)
+ }
_connectionState.emit(mappedState)
}
.launchIn(scope)
+ var retryCount = 0
+ var retryDelayMs = INITIAL_RETRY_DELAY_MS
while (p.state.value !is State.Connected) {
- autoConnect =
+ autoConnect.value =
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()
@@ -170,50 +185,52 @@ class KableBleConnection(private val scope: CoroutineScope) : BleConnection {
false
} catch (e: CancellationException) {
throw e
- } 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
+ } 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
}
- Logger.d { "[${device.address}] Direct connect failed, falling back to autoConnect" }
- delay(AUTOCONNECT_FALLBACK_DELAY)
+ Logger.d { "[${device.address}] Connect retry $retryCount, backoff ${retryDelayMs}ms" }
+ delay(retryDelayMs)
+ retryDelayMs = (retryDelayMs * BACKOFF_MULTIPLIER).coerceAtMost(MAX_RETRY_DELAY_MS)
true
}
}
}
@Suppress("TooGenericExceptionCaught", "SwallowedException")
- override suspend fun connectAndAwait(device: BleDevice, timeout: Duration): BleConnectionState = try {
- withTimeout(timeout) {
+ override suspend fun connectAndAwait(device: BleDevice, timeoutMs: Long): BleConnectionState = try {
+ withTimeout(timeoutMs) {
connect(device)
BleConnectionState.Connected
}
} catch (_: TimeoutCancellationException) {
// Our own timeout expired — treat as a failed attempt so callers can retry.
- BleConnectionState.Disconnected(DisconnectReason.Timeout)
+ BleConnectionState.Disconnected
} catch (e: CancellationException) {
// External cancellation (scope closed) — must propagate.
throw e
} catch (_: Exception) {
- BleConnectionState.Disconnected(DisconnectReason.ConnectionFailed)
+ BleConnectionState.Disconnected
}
override suspend fun disconnect() = withContext(NonCancellable) {
// Emit Disconnected before cancelling stateJob so downstream collectors see the
// state transition. If we cancel stateJob first, the peripheral's state flow
// emission of Disconnected is never forwarded to _connectionState.
- _connectionState.emit(BleConnectionState.Disconnected(DisconnectReason.LocalDisconnect))
+ _connectionState.emit(BleConnectionState.Disconnected)
stateJob?.cancel()
stateJob = null
-
- safeClosePeripheral("disconnect")
+ peripheral?.disconnect()
+ peripheral?.close()
peripheral = null
connectionScope = null
- ActiveBleConnection.active = null
+ ActiveBleConnection.activePeripheral = null
+ ActiveBleConnection.activeAddress = null
_deviceFlow.emit(null)
}
@@ -230,29 +247,4 @@ 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 13b8a1663..d0f3a7168 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,11 +21,5 @@ 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/MeshtasticBleDevice.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
similarity index 51%
rename from core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt
rename to core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
index 3342cf24f..455779937 100644
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticBleDevice.kt
+++ b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleDevice.kt
@@ -17,44 +17,32 @@
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
-/**
- * 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 {
+class KableBleDevice(val advertisement: Advertisement) : BleDevice {
+ override val name: String?
+ get() = advertisement.name
- private val _state = MutableStateFlow(BleConnectionState.Disconnected())
- override val state: StateFlow = _state.asStateFlow()
+ override val address: String
+ get() = advertisement.identifier.toString()
+
+ private val _state = MutableStateFlow(BleConnectionState.Disconnected)
+ override val state: StateFlow = _state
// 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.active?.address == address
+ get() = _state.value is BleConnectionState.Connected || ActiveBleConnection.activeAddress == address
- @OptIn(ExperimentalApi::class)
+ @OptIn(com.juul.kable.ExperimentalApi::class)
override suspend fun readRssi(): Int {
- val active = ActiveBleConnection.active
- return if (active != null && active.address == address) {
- active.peripheral.rssi()
+ val peripheral = ActiveBleConnection.activePeripheral
+ return if (peripheral != null && ActiveBleConnection.activeAddress == address) {
+ peripheral.rssi()
} else {
- advertisement?.rssi ?: 0
+ advertisement.rssi
}
}
@@ -62,7 +50,6 @@ class MeshtasticBleDevice(
// 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/KableBleScanner.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KableBleScanner.kt
index 5e91b3459..d9e27704f 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,7 +17,6 @@
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
@@ -29,10 +28,6 @@ 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
@@ -48,15 +43,7 @@ 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(
- MeshtasticBleDevice(
- address = advertisement.identifier.toString(),
- name = advertisement.name,
- advertisement = advertisement,
- ),
- )
- }
+ scanner.advertisements.collect { advertisement -> send(KableBleDevice(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 3f0e61864..46ace854f 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,101 +18,110 @@ 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.
*
- * 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.
+ * Supports both the modern `FROMRADIOSYNC` characteristic (single observe stream) and the legacy `FROMNUM` +
+ * `FROMRADIO` polling fallback for older firmware versions.
*/
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 val TRANSIENT_RETRY_DELAY = 500.milliseconds
+ private const val TRANSIENT_RETRY_DELAY_MS = 500L
}
- private val subscriptionReady = CompletableDeferred()
-
- /** Seed with replay=1 so the config-handshake drain starts before FROMNUM notifications are gated in. */
+ // 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 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 {
- if (service.hasCharacteristic(fromNum)) {
- service
- .observe(fromNum) {
- Logger.d { "FROMNUM CCCD written — notifications enabled" }
- subscriptionReady.complete(Unit)
+ 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) }
}
- .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
+ }
+ // 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)
+ }
}
- 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)
}
}
}
}
- 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.
+ @Suppress("TooGenericExceptionCaught", "SwallowedException")
+ override val logRadio: Flow = channelFlow {
+ try {
+ if (service.hasCharacteristic(logRadioChar)) {
+ service.observe(logRadioChar).collect { send(it) }
}
- } else {
- emptyFlow()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (_: Exception) {
+ // logRadio is optional, ignore if not found
}
+ }
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 4bd395dc5..7a03a3d89 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,33 +25,14 @@ 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? = 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)
+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
+ }
+ }
}
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
deleted file mode 100644
index 6884dc9e1..000000000
--- a/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/KermitLogEngine.kt
+++ /dev/null
@@ -1,51 +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 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 f69214187..389516521 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,6 +38,8 @@ 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/MeshtasticRadioProfile.kt b/core/ble/src/commonMain/kotlin/org/meshtastic/core/ble/MeshtasticRadioProfile.kt
index 7a69e9524..d1a557a42 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,22 +28,4 @@ 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
deleted file mode 100644
index 1170b973b..000000000
--- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/BleExceptionClassifierTest.kt
+++ /dev/null
@@ -1,67 +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 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
deleted file mode 100644
index d947dd04d..000000000
--- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/DisconnectReasonTest.kt
+++ /dev/null
@@ -1,51 +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 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
deleted file mode 100644
index 64286fd70..000000000
--- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableMeshtasticRadioProfileTest.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.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
deleted file mode 100644
index 18c7be4da..000000000
--- a/core/ble/src/commonTest/kotlin/org/meshtastic/core/ble/KableStateMappingTest.kt
+++ /dev/null
@@ -1,143 +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 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 979586213..da7700ac5 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. `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.
+### 2. `ByteUtils.kt`
+Low-level operations for working with `ByteArray` and binary data, essential for parsing radio protocol packets.
### 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 e4d94943e..08ec08865 100644
--- a/core/common/build.gradle.kts
+++ b/core/common/build.gradle.kts
@@ -37,7 +37,6 @@ 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
new file mode 100644
index 000000000..a99bccd84
--- /dev/null
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/CommonUri.android.kt
@@ -0,0 +1,45 @@
+/*
+ * 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/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
similarity index 63%
rename from feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
rename to core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
index 3ef5c44ef..7669a66b0 100644
--- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/TestApplicationCoroutineScope.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/MeshtasticUriExt.kt
@@ -14,13 +14,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.feature.firmware
+package org.meshtastic.core.common.util
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import org.meshtastic.core.common.di.ApplicationCoroutineScope
+import android.net.Uri
-internal class TestApplicationCoroutineScope(dispatcher: CoroutineDispatcher) :
- ApplicationCoroutineScope,
- CoroutineScope by CoroutineScope(SupervisorJob() + dispatcher)
+/** Converts a multiplatform [MeshtasticUri] into an Android [Uri]. */
+fun MeshtasticUri.toAndroidUri(): Uri = Uri.parse(this.uriString)
+
+/** Converts an Android [Uri] into a multiplatform [MeshtasticUri]. */
+fun Uri.toMeshtasticUri(): MeshtasticUri = MeshtasticUri(this.toString())
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
new file mode 100644
index 000000000..c27040e73
--- /dev/null
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/ByteUtils.kt
@@ -0,0 +1,25 @@
+/*
+ * 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
deleted file mode 100644
index 2a27b9690..000000000
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/di/ApplicationCoroutineScope.kt
+++ /dev/null
@@ -1,39 +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.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/CommonUri.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/CommonUri.kt
index 00b15861f..7079cbf5e 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,14 +16,22 @@
*/
package org.meshtastic.core.common.util
-import com.eygraber.uri.Uri
+/** Platform-agnostic URI representation to decouple core logic from android.net.Uri. */
+expect class CommonUri {
+ val host: String?
+ val fragment: String?
+ val pathSegments: List
-/**
- * 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
+ 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
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 92137375c..ccd565286 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,7 +17,6 @@
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. */
@@ -48,12 +47,10 @@ fun ignoreException(silent: Boolean = false, inner: () -> Unit) {
}
}
-/** Suspend-compatible variant of [ignoreException]. Re-throws [CancellationException]. */
+/** Suspend-compatible variant of [ignoreException]. */
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" }
@@ -72,41 +69,3 @@ 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 7a24819a7..d54455df8 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,114 +16,5 @@
*/
package org.meshtastic.core.common.util
-/**
- * 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
+/** Multiplatform string formatting helper. */
+expect fun formatString(pattern: String, vararg args: Any?): String
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
index 1abb8807c..e3612dfda 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/HomoglyphCharacterStringTransformer.kt
@@ -79,7 +79,9 @@ object HomoglyphCharacterStringTransformer {
* @param value original string value.
* @return optimized string value.
*/
- fun optimizeUtf8StringWithHomoglyphs(value: String): String = buildString {
- for (c in value) append(homoglyphCharactersSubstitutionMapping[c] ?: c)
+ fun optimizeUtf8StringWithHomoglyphs(value: String): String {
+ val stringBuilder = StringBuilder()
+ for (c in value.toCharArray()) stringBuilder.append(homoglyphCharactersSubstitutionMapping[c] ?: c)
+ return stringBuilder.toString()
}
}
diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
similarity index 62%
rename from core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
rename to core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
index 1072801c6..0babff5b1 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/AddressUtils.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeshtasticUri.kt
@@ -1,5 +1,5 @@
/*
- * Copyright (c) 2025-2026 Meshtastic LLC
+ * Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,14 +17,13 @@
package org.meshtastic.core.common.util
/**
- * Normalizes a BLE/device address to a canonical uppercase form with colons removed. Returns `"DEFAULT"` for null,
- * blank, or sentinel values (`"N"`, `"NULL"`).
+ * 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.
*/
-fun normalizeAddress(addr: String?): String {
- val u = addr?.trim()?.uppercase()
- return when {
- u.isNullOrBlank() -> "DEFAULT"
- u == "N" || u == "NULL" -> "DEFAULT"
- else -> u.replace(":", "")
+data class MeshtasticUri(val uriString: String) {
+ override fun toString(): String = uriString
+
+ companion object {
+ fun parse(uriString: String): MeshtasticUri = MeshtasticUri(uriString)
}
}
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
deleted file mode 100644
index 51905ff41..000000000
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt
+++ /dev/null
@@ -1,60 +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
-
-/**
- * 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/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
similarity index 95%
rename from core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.kt
index 14dfd72c8..51f6a5c76 100644
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/CommonUtilsTest.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/ByteUtilsTest.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.model.util
+package org.meshtastic.core.common
import kotlin.test.Test
import kotlin.test.assertEquals
-class CommonUtilsTest {
+class ByteUtilsTest {
@Test
fun testByteArrayOfInts() {
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
deleted file mode 100644
index 040861b8d..000000000
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/AddressUtilsTest.kt
+++ /dev/null
@@ -1,72 +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
-
-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/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/FormatStringTest.kt
index de2d20e9e..94b81f0fb 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,48 +93,4 @@ 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/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
similarity index 67%
rename from app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
rename to core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
index 04f0350c8..7ca9f9fe8 100644
--- a/app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt
+++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MeshtasticUriTest.kt
@@ -14,13 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package org.meshtastic.app.di
+package org.meshtastic.core.common.util
-import org.koin.core.annotation.KoinApplication
+import kotlin.test.Test
+import kotlin.test.assertEquals
-/**
- * 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
+class MeshtasticUriTest {
+ @Test
+ fun testParseAndToString() {
+ val uriString = "content://com.example.provider/file.txt"
+ val uri = MeshtasticUri.parse(uriString)
+ assertEquals(uriString, uri.toString())
+ }
+}
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
deleted file mode 100644
index 94781fca3..000000000
--- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt
+++ /dev/null
@@ -1,143 +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
-
-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
new file mode 100644
index 000000000..c2e95a5b0
--- /dev/null
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/Formatter.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.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 7556105b3..35e2906ff 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,6 +22,20 @@ 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/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
new file mode 100644
index 000000000..a450b9856
--- /dev/null
+++ b/core/common/src/jvmAndroidMain/kotlin/org/meshtastic/core/common/util/Formatter.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.core.common.util
+
+/** JVM/Android implementation of string formatting. */
+actual fun formatString(pattern: String, vararg args: Any?): String = String.format(pattern, *args)
diff --git a/core/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
new file mode 100644
index 000000000..c10c015bc
--- /dev/null
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/CommonUri.jvm.kt
@@ -0,0 +1,49 @@
+/*
+ * 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 43ead91a2..4b8abdbd3 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,6 +17,9 @@
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
@@ -73,7 +76,7 @@ actual object DateFormatter {
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
- shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
+ DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(timestampMillis)
}
@Suppress("MagicNumber")
@@ -98,6 +101,21 @@ 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 628528391..b0b9e8c5f 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,7 +19,6 @@ 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
@@ -95,7 +94,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
"lastRequest=$lastRequest window=$window max=$max",
)
- safeCatching {
+ runCatching {
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 ab4f3a551..027947453 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,15 +18,12 @@ 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
@@ -45,7 +42,6 @@ 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
@@ -64,13 +60,16 @@ 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
@@ -96,7 +95,7 @@ class MeshActionHandlerImpl(
is ServiceAction.ImportContact -> handleImportContact(action, myNodeNum)
is ServiceAction.SendContact -> {
val accepted =
- safeCatching {
+ runCatching {
commandSender.sendAdminAwait(myNodeNum) { AdminMessage(add_contact = action.contact) }
}
.getOrDefault(false)
@@ -203,13 +202,13 @@ class MeshActionHandlerImpl(
commandSender.sendData(p)
serviceBroadcasts.broadcastMessageStatus(p.id, p.status ?: MessageStatus.UNKNOWN)
dataHandler.value.rememberDataPacket(p, myNodeNum, false)
- val bytes = p.bytes ?: ByteString.EMPTY
+ val bytes = p.bytes ?: okio.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 = uiPrefs.shouldProvideNodeLocation(myNodeNum).value
+ val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
@@ -360,7 +359,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() ?: ByteString.EMPTY)
+ AdminMessage.OTAEvent(reboot_ota_mode = otaMode, ota_hash = hash?.toByteString() ?: okio.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 cc5cc4319..f492dcd65 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 org.koin.core.annotation.Named
+import okio.IOException
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,7 +38,9 @@ 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
@@ -53,14 +55,18 @@ class MeshConfigFlowManagerImpl(
private val serviceBroadcasts: ServiceBroadcasts,
private val analytics: PlatformAnalytics,
private val commandSender: CommandSender,
- private val heartbeatSender: DataLayerHeartbeatSender,
- @Named("ServiceScope") private val scope: CoroutineScope,
+ private val packetHandler: PacketHandler,
) : 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.
@@ -78,7 +84,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, val metadata: DeviceMetadata? = null) :
+ data class ReceivingConfig(val rawMyNodeInfo: ProtoMyNodeInfo, var metadata: DeviceMetadata? = null) :
HandshakeState()
/**
@@ -87,8 +93,10 @@ 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: List = emptyList()) :
- HandshakeState()
+ data class ReceivingNodeInfo(
+ val myNodeInfo: SharedMyNodeInfo,
+ val nodes: MutableList = mutableListOf(),
+ ) : HandshakeState()
/** Both stages finished. The app is fully connected. */
data class Complete(val myNodeInfo: SharedMyNodeInfo) : HandshakeState()
@@ -134,31 +142,28 @@ 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)
- heartbeatSender.sendHeartbeat("inter-stage")
+ sendHeartbeat()
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)" }
@@ -166,12 +171,16 @@ 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 =
- state.nodes.mapNotNull { nodeInfo ->
+ nodesToProcess.mapNotNull { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo, withBroadcast = false)
nodeManager.nodeDBbyNodeNum[nodeInfo.num]
?: run {
@@ -222,7 +231,7 @@ class MeshConfigFlowManagerImpl(
Logger.i { "Local Metadata received: ${metadata.firmware_version}" }
val state = handshakeState
if (state is HandshakeState.ReceivingConfig) {
- handshakeState = state.copy(metadata = metadata)
+ state.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()) {
@@ -236,7 +245,7 @@ class MeshConfigFlowManagerImpl(
override fun handleNodeInfo(info: NodeInfo) {
val state = handshakeState
if (state is HandshakeState.ReceivingNodeInfo) {
- handshakeState = state.copy(nodes = state.nodes + info)
+ state.nodes.add(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 b622cedbf..06d973204 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,7 +22,6 @@ 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
@@ -41,8 +40,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()
@@ -50,7 +49,8 @@ class MeshConfigHandlerImpl(
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
override val moduleConfig = _moduleConfig.asStateFlow()
- init {
+ override fun start(scope: CoroutineScope) {
+ this.scope = scope
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 022f3548d..5954b579c 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,16 +19,13 @@ 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
@@ -60,7 +57,6 @@ 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
@@ -85,26 +81,17 @@ 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 {
- /**
- * 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 lateinit var scope: CoroutineScope
private var sleepTimeout: Job? = null
private var locationRequestsJob: Job? = null
private var handshakeTimeout: Job? = null
private var connectTimeMsec = 0L
private var connectionRestored = false
- 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.
+ @OptIn(FlowPreview::class)
+ override fun start(scope: CoroutineScope) {
+ this.scope = scope
radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(scope)
// Ensure notification title and content stay in sync with state changes
@@ -138,13 +125,6 @@ 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
@@ -161,22 +141,20 @@ class MeshConnectionManagerImpl(
onConnectionChanged(effectiveState)
}
- private suspend fun onConnectionChanged(c: ConnectionState) = connectionMutex.withLock {
+ private fun onConnectionChanged(c: ConnectionState) {
val current = serviceRepository.connectionState.value
- if (current == c) return@withLock
+ if (current == c) return
// 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@withLock
+ return
}
Logger.i { "onConnectionChanged: $current -> $c" }
sleepTimeout?.cancel()
sleepTimeout = null
- preHandshakeJob?.cancel()
- preHandshakeJob = null
handshakeTimeout?.cancel()
handshakeTimeout = null
@@ -197,26 +175,16 @@ class MeshConnectionManagerImpl(
serviceRepository.setConnectionState(ConnectionState.Connecting)
}
serviceBroadcasts.broadcastConnection()
+ Logger.i { "Starting mesh handshake (Stage 1)" }
connectTimeMsec = nowMillis
-
- // 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()
- }
+ startConfigOnly()
}
- private fun startHandshakeStallGuard(stage: Int, timeout: Duration, action: () -> Unit) {
+ private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) {
handshakeTimeout?.cancel()
handshakeTimeout =
scope.handledLaunch {
- delay(timeout)
+ delay(HANDSHAKE_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
@@ -237,7 +205,7 @@ class MeshConnectionManagerImpl(
private fun tearDownConnection() {
packetHandler.stopPacketQueue()
- commandSender.setSessionPasskey(ByteString.EMPTY) // Prevent stale passkey on reconnect.
+ commandSender.setSessionPasskey(okio.ByteString.EMPTY) // Prevent stale passkey on reconnect.
locationManager.stop()
mqttManager.stop()
}
@@ -292,19 +260,19 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE)) }
- startHandshakeStallGuard(1, HANDSHAKE_TIMEOUT_STAGE1, action)
+ startHandshakeStallGuard(1, action)
action()
}
override fun startNodeInfoOnly() {
val action = { packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE)) }
- startHandshakeStallGuard(2, HANDSHAKE_TIMEOUT_STAGE2, action)
+ startHandshakeStallGuard(2, action)
action()
}
override fun onRadioConfigLoaded() {
scope.handledLaunch {
- val queuedPackets = packetRepository.getQueuedPackets()
+ val queuedPackets = packetRepository.getQueuedPackets() ?: emptyList()
queuedPackets.forEach { packet ->
try {
workerManager.enqueueSendMessage(packet.id)
@@ -334,7 +302,8 @@ class MeshConnectionManagerImpl(
// Start MQTT if enabled
scope.handledLaunch {
val moduleConfig = radioConfigRepository.moduleConfigFlow.first()
- mqttManager.startProxy(
+ mqttManager.start(
+ scope,
moduleConfig.mqtt?.enabled == true,
moduleConfig.mqtt?.proxy_to_client_enabled == true,
)
@@ -381,12 +350,11 @@ class MeshConnectionManagerImpl(
updateStatusNotification(t)
}
- override fun updateStatusNotification(telemetry: Telemetry?) {
+ override fun updateStatusNotification(telemetry: Telemetry?): Any =
serviceNotifications.updateServiceStateNotification(
serviceRepository.connectionState.value,
telemetry = telemetry,
)
- }
companion object {
private const val DEVICE_SLEEP_TIMEOUT_SECONDS = 30
@@ -396,23 +364,7 @@ class MeshConnectionManagerImpl(
// cap, routers (ls_secs=3600) leave the UI in DeviceSleep for over an hour.
private const val MAX_SLEEP_TIMEOUT_SECONDS = 300
- /**
- * 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
+ private val HANDSHAKE_TIMEOUT = 30.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 384f722d8..07521b21c 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,8 +22,6 @@ 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
@@ -96,8 +94,14 @@ 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(
@@ -248,7 +252,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 = ByteString.EMPTY) else it }
+ .let { if (it.is_licensed == true) it.copy(public_key = okio.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 d9d21ad8b..9fd28ecb4 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,7 +24,6 @@ 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
@@ -32,8 +31,6 @@ 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
@@ -56,8 +53,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()
@@ -71,14 +68,15 @@ class MeshMessageProcessorImpl(
@Volatile private var lastLocalNodeRefreshMs = 0L
private val earlyMutex = Mutex()
- private val earlyReceivedPackets = ArrayDeque()
+ private val earlyReceivedPackets = kotlin.collections.ArrayDeque()
private val maxEarlyPacketBuffer = 10240
override fun clearEarlyPackets() {
scope.launch { earlyMutex.withLock { earlyReceivedPackets.clear() } }
}
- init {
+ override fun start(scope: CoroutineScope) {
+ this.scope = scope
nodeManager.isNodeDbReady
.onEach { ready ->
if (ready) {
@@ -98,7 +96,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."
}
}
}
@@ -127,11 +125,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!!.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.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.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 8973589bd..aaf109be9 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,6 +16,7 @@
*/
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
@@ -63,4 +64,13 @@ 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 5693d343b..969b67a2f 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,28 +20,14 @@ 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
@@ -50,33 +36,22 @@ 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 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) {
+ override fun start(scope: CoroutineScope, enabled: Boolean, proxyToClientEnabled: Boolean) {
+ this.scope = scope
if (mqttMessageFlow?.isActive == true) return
if (enabled && proxyToClientEnabled) {
- proxyActive.value = true
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.catch { throwable ->
- 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)
+ serviceRepository.setErrorMessage(
+ text = "MqttClientProxy failed: $throwable",
+ severity = Severity.Warn,
+ )
}
.launchIn(scope)
}
@@ -88,7 +63,6 @@ class MqttManagerImpl(
mqttMessageFlow?.cancel()
mqttMessageFlow = null
}
- proxyActive.value = false
}
override fun handleMqttProxyMessage(message: MqttClientProxyMessage) {
@@ -105,57 +79,4 @@ 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 3f483ba25..4019e5a9b 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,6 +20,7 @@ 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
@@ -36,11 +37,16 @@ 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 fe6d22f4c..9ce4ba05d 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,7 +24,6 @@ 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
@@ -60,8 +59,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())
@@ -89,6 +88,10 @@ 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 e2e9a8432..1d4d11adc 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,15 +22,12 @@ 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
@@ -63,7 +60,6 @@ class PacketHandlerImpl(
private val radioInterfaceService: RadioInterfaceService,
private val meshLogRepository: Lazy,
private val serviceRepository: ServiceRepository,
- @Named("ServiceScope") private val scope: CoroutineScope,
) : PacketHandler {
companion object {
@@ -71,15 +67,11 @@ 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
@@ -87,18 +79,9 @@ class PacketHandlerImpl(
private val responseMutex = Mutex()
private val queueResponse = mutableMapOf>()
- 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 start(scope: CoroutineScope) {
+ this.scope = scope
+ queueStopped = false // Safe: called before any concurrent operations on this scope.
}
override fun sendToRadio(p: ToRadio) {
@@ -125,9 +108,13 @@ class PacketHandlerImpl(
}
override fun sendToRadio(packet: MeshPacket) {
- // 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)
+ scope.launch {
+ queueMutex.withLock {
+ queueStopped = false // Allow queue to resume after a disconnect/reconnect cycle.
+ queuedPackets.add(packet)
+ startPacketQueueLocked()
+ }
+ }
}
@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 e8ab4eeb7..4f71879ce 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,7 +20,6 @@ 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
@@ -46,8 +45,12 @@ 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 4887ff19b..205dd30e2 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,7 +21,6 @@ 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
@@ -50,12 +49,16 @@ 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 5d2feb65e..5e8d954f6 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,7 +22,6 @@ 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
@@ -43,11 +42,15 @@ 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) }
}
@@ -65,7 +68,7 @@ class TracerouteHandlerImpl(
routeDiscovery.getTracerouteResponse(
getUser = { num ->
nodeManager.nodeDBbyNodeNum[num]?.let { "${it.user.long_name} (${it.user.short_name})" }
- ?: "Unknown"
+ ?: "Unknown" // TODO: Use core:resources once available in core:data
},
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 fdcc6d344..338a0d6ea 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,7 +20,6 @@ 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
@@ -99,7 +98,7 @@ class DeviceHardwareRepositoryImpl(
}
// 2. Fetch from remote API
- safeCatching {
+ runCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
@@ -158,7 +157,7 @@ class DeviceHardwareRepositoryImpl(
hwModel: Int,
target: String?,
quirks: List,
- ): Result = safeCatching {
+ ): Result = runCatching {
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 8f3154815..a47a5381f 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,7 +21,6 @@ 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
@@ -98,7 +97,7 @@ open class FirmwareReleaseRepositoryImpl(
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
- safeCatching {
+ runCatching {
Logger.d { "Fetching fresh firmware releases from remote API." }
val networkReleases = remoteDataSource.getFirmwareReleases()
@@ -111,7 +110,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." }
- safeCatching {
+ runCatching {
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 149c62d2b..f6a49f190 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,8 +28,6 @@ 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
@@ -110,7 +108,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) =
@@ -156,14 +154,13 @@ 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(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
+ 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
}
}
}
@@ -180,16 +177,13 @@ 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(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
+ 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
}
}
@@ -210,16 +204,13 @@ 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(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
+ 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
}
}
@@ -239,22 +230,6 @@ 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 5b29e9f26..6ac094e48 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,7 +25,6 @@ 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
@@ -47,7 +46,6 @@ 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
@@ -69,7 +67,6 @@ 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)
@@ -92,29 +89,28 @@ class MeshActionHandlerImplTest {
every { nodeManager.myNodeNum } returns myNodeNumFlow
every { nodeManager.getMyId() } returns "!12345678"
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
- }
- 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,
- )
+ 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,
+ )
+ }
// ---- handleUpdateLastAddress (device-switch path — P0 critical) ----
@Test
fun handleUpdateLastAddress_differentAddress_switchesDatabaseAndClearsState() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -132,7 +128,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_sameAddress_noOp() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("same_addr")
@@ -145,7 +141,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_nullAddress_switchesIfDifferent() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old_addr")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -160,7 +156,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_nullToNull_noOp() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow(null)
@@ -172,7 +168,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleUpdateLastAddress_executesStepsInOrder() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { meshPrefs.deviceAddress } returns MutableStateFlow("old")
everySuspend { databaseManager.switchActiveDatabase(any()) } returns Unit
@@ -191,7 +187,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_nullMyNodeNum_doesNothing() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
myNodeNumFlow.value = null
val node = createTestNode(REMOTE_NODE_NUM)
@@ -205,7 +201,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_favorite_sendsSetFavoriteWhenNotFavorite() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = false)
handler.onServiceAction(ServiceAction.Favorite(node))
@@ -217,7 +213,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_favorite_sendsRemoveFavoriteWhenAlreadyFavorite() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isFavorite = true)
handler.onServiceAction(ServiceAction.Favorite(node))
@@ -231,7 +227,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_ignore_togglesAndUpdatesFilteredBySender() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isIgnored = false)
handler.onServiceAction(ServiceAction.Ignore(node))
@@ -246,7 +242,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_mute_togglesMutedState() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val node = createTestNode(REMOTE_NODE_NUM, isMuted = false)
handler.onServiceAction(ServiceAction.Mute(node))
@@ -260,7 +256,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_getDeviceMetadata_sendsAdminRequest() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
handler.onServiceAction(ServiceAction.GetDeviceMetadata(REMOTE_NODE_NUM))
advanceUntilIdle()
@@ -272,7 +268,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_sendContact_completesWithTrueOnSuccess() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns true
val action = ServiceAction.SendContact(SharedContact())
@@ -285,7 +281,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_sendContact_completesWithFalseOnFailure() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
everySuspend { commandSender.sendAdminAwait(any(), any(), any(), any()) } returns false
val action = ServiceAction.SendContact(SharedContact())
@@ -300,7 +296,7 @@ class MeshActionHandlerImplTest {
@Test
fun onServiceAction_importContact_sendsAdminAndUpdatesNode() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val contact =
SharedContact(node_num = REMOTE_NODE_NUM, user = User(id = "!abcdef12", long_name = "TestUser"))
@@ -315,7 +311,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetOwner_sendsAdminAndUpdatesLocalNode() {
- handler = createHandler(testScope)
+ handler.start(testScope)
val meshUser =
MeshUser(
id = "!12345678",
@@ -335,7 +331,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSend_sendsDataAndBroadcastsStatus() {
- handler = createHandler(testScope)
+ handler.start(testScope)
val packet = DataPacket(to = "!deadbeef", dataType = 1, bytes = null, channel = 0)
handler.handleSend(packet, MY_NODE_NUM)
@@ -349,7 +345,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_sameNode_doesNothing() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleRequestPosition(MY_NODE_NUM, Position(0.0, 0.0, 0), MY_NODE_NUM)
@@ -358,8 +354,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_validPosition_usesGivenPosition() {
- handler = createHandler(testScope)
- every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
+ handler.start(testScope)
+ every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
@@ -369,8 +365,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_provideLocation_invalidPosition_fallsBackToNodeDB() {
- handler = createHandler(testScope)
- every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
+ handler.start(testScope)
+ every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(true)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
val invalidPosition = Position(0.0, 0.0, 0)
@@ -382,8 +378,8 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestPosition_doNotProvide_sendsZeroPosition() {
- handler = createHandler(testScope)
- every { uiPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
+ handler.start(testScope)
+ every { meshPrefs.shouldProvideNodeLocation(MY_NODE_NUM) } returns MutableStateFlow(false)
val validPosition = Position(37.7749, -122.4194, 10)
handler.handleRequestPosition(REMOTE_NODE_NUM, validPosition, MY_NODE_NUM)
@@ -396,7 +392,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetConfig_decodesAndSendsAdmin_thenPersistsLocally() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
everySuspend { radioConfigRepository.setLocalConfig(any()) } returns Unit
val config = Config(lora = Config.LoRaConfig(hop_limit = 5))
@@ -413,7 +409,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetModuleConfig_ownNode_persistsLocally() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
everySuspend { radioConfigRepository.setLocalModuleConfig(any()) } returns Unit
@@ -429,7 +425,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetModuleConfig_remoteNode_doesNotPersistLocally() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
myNodeNumFlow.value = MY_NODE_NUM
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
@@ -446,7 +442,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetChannel_nonNullPayload_decodesAndPersists() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
everySuspend { radioConfigRepository.updateChannelSettings(any()) } returns Unit
val channel = Channel(index = 1)
@@ -461,7 +457,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetChannel_nullPayload_doesNothing() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleSetChannel(null, MY_NODE_NUM)
@@ -472,7 +468,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRemoveByNodenum_removesAndSendsAdmin() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleRemoveByNodenum(REMOTE_NODE_NUM, 99, MY_NODE_NUM)
@@ -484,7 +480,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteOwner_decodesAndSendsAdmin() {
- handler = createHandler(testScope)
+ handler.start(testScope)
val user = User(id = "!remote01", long_name = "Remote", short_name = "RM")
val payload = User.ADAPTER.encode(user)
@@ -499,7 +495,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleGetRemoteConfig_sessionkeyConfig_sendsDeviceMetadataRequest() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.SESSIONKEY_CONFIG.value)
@@ -508,7 +504,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleGetRemoteConfig_regularConfig_sendsConfigRequest() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleGetRemoteConfig(1, REMOTE_NODE_NUM, AdminMessage.ConfigType.LORA_CONFIG.value)
@@ -519,7 +515,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteChannel_nullPayload_doesNothing() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleSetRemoteChannel(1, REMOTE_NODE_NUM, null)
@@ -528,7 +524,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleSetRemoteChannel_nonNullPayload_decodesAndSendsAdmin() {
- handler = createHandler(testScope)
+ handler.start(testScope)
val channel = Channel(index = 2)
val payload = Channel.ADAPTER.encode(channel)
@@ -542,7 +538,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestRebootOta_withNullHash_sendsAdmin() {
- handler = createHandler(testScope)
+ handler.start(testScope)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 0, null)
@@ -551,7 +547,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestRebootOta_withHash_sendsAdmin() {
- handler = createHandler(testScope)
+ handler.start(testScope)
val hash = byteArrayOf(0x01, 0x02, 0x03)
handler.handleRequestRebootOta(1, REMOTE_NODE_NUM, 1, hash)
@@ -563,7 +559,7 @@ class MeshActionHandlerImplTest {
@Test
fun handleRequestNodedbReset_sendsAdminWithPreserveFavorites() {
- handler = createHandler(testScope)
+ handler.start(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 fdcd8ed44..9580d5363 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,7 +17,6 @@
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
@@ -98,9 +97,9 @@ class MeshConfigFlowManagerImplTest {
serviceBroadcasts = serviceBroadcasts,
analytics = analytics,
commandSender = commandSender,
- heartbeatSender = DataLayerHeartbeatSender(packetHandler),
- scope = testScope,
+ packetHandler = packetHandler,
)
+ manager.start(testScope)
}
// ---------- handleMyInfo ----------
@@ -175,49 +174,6 @@ 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 bf3247815..b71942d0e 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,7 +23,6 @@ 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
@@ -61,20 +60,20 @@ class MeshConfigHandlerImplTest {
fun setUp() {
every { radioConfigRepository.localConfigFlow } returns localConfigFlow
every { radioConfigRepository.moduleConfigFlow } returns moduleConfigFlow
- }
- private fun createHandler(scope: CoroutineScope): MeshConfigHandlerImpl = MeshConfigHandlerImpl(
- radioConfigRepository = radioConfigRepository,
- serviceRepository = serviceRepository,
- nodeManager = nodeManager,
- scope = scope,
- )
+ handler =
+ MeshConfigHandlerImpl(
+ radioConfigRepository = radioConfigRepository,
+ serviceRepository = serviceRepository,
+ nodeManager = nodeManager,
+ )
+ }
// ---------- start and flow wiring ----------
@Test
fun `start wires localConfig flow from repository`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val config = LocalConfig(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER))
localConfigFlow.value = config
advanceUntilIdle()
@@ -84,7 +83,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `start wires moduleConfig flow from repository`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val config = LocalModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
moduleConfigFlow.value = config
advanceUntilIdle()
@@ -96,7 +95,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
handler.handleDeviceConfig(config)
advanceUntilIdle()
@@ -107,7 +106,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceConfig handles all config variants`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val configs =
listOf(
Config(position = Config.PositionConfig()),
@@ -132,7 +131,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig persists config and updates progress`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val config = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
handler.handleModuleConfig(config)
advanceUntilIdle()
@@ -143,7 +142,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig with statusmessage updates node status`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val myNum = 123
every { nodeManager.myNodeNum } returns MutableStateFlow(myNum)
@@ -156,7 +155,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleModuleConfig with statusmessage skipped when myNodeNum is null`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
val config = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Active"))
@@ -169,7 +168,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel persists channel settings`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
val channel = Channel(index = 0)
handler.handleChannel(channel)
advanceUntilIdle()
@@ -179,7 +178,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel shows progress with max channels when myNodeInfo available`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns
MyNodeInfo(
myNodeNum = 123,
@@ -207,7 +206,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleChannel shows progress without max channels when myNodeInfo unavailable`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(backgroundScope)
every { nodeManager.getMyNodeInfo() } returns null
val channel = Channel(index = 0)
@@ -221,7 +220,7 @@ class MeshConfigHandlerImplTest {
@Test
fun `handleDeviceUIConfig persists config`() = runTest(testDispatcher) {
- handler = createHandler(backgroundScope)
+ handler.start(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 07c8914ad..5263254d3 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,10 +24,9 @@ import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
-import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
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
@@ -61,7 +60,7 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
-@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+@OptIn(ExperimentalCoroutinesApi::class)
class MeshConnectionManagerImplTest {
private val radioInterfaceService = mock(MockMode.autofill)
private val serviceRepository = mock(MockMode.autofill)
@@ -109,35 +108,37 @@ 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,
+ )
}
- 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
+ @AfterTest fun tearDown() {}
@Test
fun `Connected state triggers broadcast and config start`() = runTest(testDispatcher) {
- manager = createManager(backgroundScope)
+ every { packetHandler.sendToRadio(any()) } returns Unit
+ every { serviceNotifications.updateServiceStateNotification(any(), any()) } returns Unit
+
+ manager.start(backgroundScope)
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
@@ -149,63 +150,20 @@ class MeshConnectionManagerImplTest {
verify { serviceBroadcasts.broadcastConnection() }
}
- @Test
- 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 = 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 { 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
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
- manager = createManager(backgroundScope)
+ manager.start(backgroundScope)
// Transition to Connected first so that Disconnected actually does something
radioConnectionState.value = ConnectionState.Connected
advanceUntilIdle()
@@ -232,9 +190,14 @@ 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 = createManager(backgroundScope)
+ manager.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
@@ -252,8 +215,13 @@ 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 = createManager(backgroundScope)
+ manager.start(backgroundScope)
advanceUntilIdle()
radioConnectionState.value = ConnectionState.DeviceSleep
@@ -268,7 +236,7 @@ class MeshConnectionManagerImplTest {
@Test
fun `onRadioConfigLoaded enqueues queued packets and sets time`() = runTest(testDispatcher) {
- manager = createManager(backgroundScope)
+ manager.start(backgroundScope)
val packetId = 456
everySuspend { packetRepository.getQueuedPackets() } returns listOf(dataPacket)
every { workerManager.enqueueSendMessage(any()) } returns Unit
@@ -289,15 +257,15 @@ class MeshConnectionManagerImplTest {
moduleConfigFlow.value = moduleConfig
every { commandSender.requestTelemetry(any(), any(), any()) } returns Unit
every { nodeManager.myNodeNum } returns MutableStateFlow(123)
- every { mqttManager.startProxy(any(), any()) } returns Unit
+ every { mqttManager.start(any(), any(), any()) } returns Unit
every { historyManager.requestHistoryReplay(any(), any(), any(), any()) } returns Unit
every { nodeManager.getMyNodeInfo() } returns null
- manager = createManager(backgroundScope)
+ manager.start(backgroundScope)
manager.onNodeDbReady()
advanceUntilIdle()
- verify { mqttManager.startProxy(true, true) }
+ verify { mqttManager.start(any(), true, true) }
verify { historyManager.requestHistoryReplay(any(), any(), any(), any()) }
}
@@ -311,9 +279,14 @@ 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 = createManager(backgroundScope)
+ manager.start(backgroundScope)
advanceUntilIdle()
// Transition to Connected then DeviceSleep
@@ -337,92 +310,4 @@ 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 022608be1..5f738b439 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 251aefabe..3090cf49e 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,7 +23,6 @@ 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
@@ -66,22 +65,22 @@ class MeshMessageProcessorImplTest {
every { nodeManager.isNodeDbReady } returns isNodeDbReady
every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum)
every { router.dataHandler } returns dataHandler
- }
- private fun createProcessor(scope: CoroutineScope): MeshMessageProcessorImpl = MeshMessageProcessorImpl(
- nodeManager = nodeManager,
- serviceRepository = serviceRepository,
- meshLogRepository = lazy { meshLogRepository },
- router = lazy { router },
- fromRadioDispatcher = fromRadioDispatcher,
- scope = scope,
- )
+ processor =
+ MeshMessageProcessorImpl(
+ nodeManager = nodeManager,
+ serviceRepository = serviceRepository,
+ meshLogRepository = lazy { meshLogRepository },
+ router = lazy { router },
+ fromRadioDispatcher = fromRadioDispatcher,
+ )
+ }
// ---------- handleFromRadio: non-packet variants ----------
@Test
fun `handleFromRadio dispatches non-packet variants to fromRadioDispatcher`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
val logRecord = LogRecord(message = "test log")
val fromRadio = FromRadio(log_record = logRecord)
val bytes = FromRadio.ADAPTER.encode(fromRadio)
@@ -94,7 +93,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `handleFromRadio falls back to LogRecord parsing when FromRadio fails`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(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")
@@ -109,7 +108,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `handleFromRadio with completely invalid bytes does not crash`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
// Invalid protobuf bytes — both parses should fail
val garbage = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0xFD.toByte())
@@ -122,7 +121,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets are buffered when node DB is not ready`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -142,7 +141,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `buffered packets are flushed when node DB becomes ready`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -166,7 +165,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `early buffer overflow drops oldest packet`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = false
// The maxEarlyPacketBuffer is 10240 — we won't actually fill it in this test,
@@ -196,7 +195,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets with rx_time 0 get current time`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -215,7 +214,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packets with non-zero rx_time keep their time`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -236,7 +235,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket updates myNode lastHeard`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -256,7 +255,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket updates sender node`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val senderNode = 999
@@ -280,7 +279,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `packet with null decoded is skipped`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val packet = MeshPacket(id = 1, from = 999, decoded = null)
@@ -294,7 +293,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `processReceivedMeshPacket with null myNodeNum skips node updates`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = true
val packet =
@@ -316,7 +315,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `clearEarlyPackets empties the buffer`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(backgroundScope)
isNodeDbReady.value = false
val packet =
@@ -343,7 +342,7 @@ class MeshMessageProcessorImplTest {
@Test
fun `FromRadio log_record variant is logged as MeshLog`() = runTest(testDispatcher) {
- processor = createProcessor(backgroundScope)
+ processor.start(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 509066867..022590467 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,7 +18,6 @@ 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
@@ -45,13 +44,12 @@ 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, testScope)
+ nodeManager = NodeManagerImpl(nodeRepository, serviceBroadcasts, notificationManager)
}
@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 e0bda6075..fe89063ef 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,7 +21,6 @@ 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
@@ -71,8 +70,8 @@ class PacketHandlerImplTest {
radioInterfaceService,
lazy { meshLogRepository },
serviceRepository,
- testScope,
)
+ handler.start(testScope)
}
@Test
@@ -85,8 +84,6 @@ class PacketHandlerImplTest {
val toRadio = ToRadio(packet = MeshPacket(id = 123))
handler.sendToRadio(toRadio)
-
- verify { radioInterfaceService.sendToRadio(any()) }
}
@Test
@@ -96,8 +93,6 @@ 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 900245332..e465aaa63 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 28bf22fdc..8f295a2b6 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
deleted file mode 100644
index c26991ac4..000000000
--- a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/38.json
+++ /dev/null
@@ -1,1052 +0,0 @@
-{
- "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 451a62174..8062afa76 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.test.runTest
+import kotlinx.coroutines.runBlocking
import okio.ByteString.Companion.toByteString
import org.junit.After
import org.junit.Before
@@ -59,7 +59,7 @@ class MigrationTest {
)
@Before
- fun createDb(): Unit = runTest {
+ fun createDb(): Unit = runBlocking {
val context = ApplicationProvider.getApplicationContext()
database =
Room.inMemoryDatabaseBuilder(
@@ -77,7 +77,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_duplicatePSK() = runTest {
+ fun testMigrateChannelsByPSK_duplicatePSK() = runBlocking {
// PSK \"AQ==\" is base64 for single byte 0x01
val pskBytes = byteArrayOf(0x01).toByteString()
@@ -103,7 +103,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_reorder() = runTest {
+ fun testMigrateChannelsByPSK_reorder() = runBlocking {
val pskA = byteArrayOf(0x01).toByteString()
val pskB = byteArrayOf(0x02).toByteString()
@@ -122,7 +122,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_disambiguateByName() = runTest {
+ fun testMigrateChannelsByPSK_disambiguateByName() = runBlocking {
val pskA = byteArrayOf(0x01).toByteString()
insertPacket(channel = 0, text = "Msg A1")
@@ -141,7 +141,7 @@ class MigrationTest {
}
@Test
- fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runTest {
+ fun testMigrateChannelsByPSK_preferSameIndexIfStillAmbiguous() = runBlocking {
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 b2c89ad73..c917ee066 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,7 +17,6 @@
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"
@@ -41,6 +40,17 @@ 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 108345265..ba5887f95 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,7 +241,6 @@ 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)) }
@@ -267,7 +266,6 @@ 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 13451e5fc..7bf9014ce 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,9 +94,8 @@ 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 = 38,
+ version = 37,
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 c1e399c97..fcdc079f2 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,15 +17,18 @@
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 {
- @Upsert suspend fun insert(deviceHardware: DeviceHardwareEntity)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(deviceHardware: DeviceHardwareEntity)
- @Upsert suspend fun insertAll(deviceHardware: List)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ 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 040941a49..0a5520a07 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,14 +17,16 @@
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 {
- @Upsert suspend fun insert(firmwareReleaseEntity: FirmwareReleaseEntity)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ 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 35d29c161..967a97ec5 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 :maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date DESC LIMIT 0,:maxItem")
fun getAllLogs(maxItem: Int): Flow>
- @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT :maxItem")
+ @Query("SELECT * FROM log ORDER BY received_date ASC LIMIT 0,: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 :maxItem
+ ORDER BY received_date DESC LIMIT 0,: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 407a4d853..e11d10f50 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,7 +17,9 @@
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
@@ -35,9 +37,6 @@ 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
}
/**
@@ -169,7 +168,8 @@ interface NodeInfoDao {
@Query("SELECT * FROM my_node")
fun getMyNodeInfo(): Flow
- @Upsert suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun setMyNodeInfo(myInfo: MyNodeEntity)
@Query("DELETE FROM my_node")
suspend fun clearMyNodeInfo()
@@ -284,15 +284,9 @@ 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
@@ -301,82 +295,17 @@ interface NodeInfoDao {
doUpsert(verifiedNode)
}
- @Upsert suspend fun putAll(nodes: List)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ 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(getVerifiedNodesForUpsert(nodes))
+ putAll(nodes.map { getVerifiedNodeForUpsert(it) })
}
/**
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 c2ef9c516..1419d51e7 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,9 +18,7 @@ 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
@@ -309,16 +307,6 @@ 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
@@ -338,15 +326,8 @@ interface PacketDao {
)
suspend fun findPacketBySfppHash(hash: ByteString): Packet?
- @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
+ @Transaction
+ suspend fun getQueuedPackets(): List? = getDataPackets().filter { it.status == MessageStatus.QUEUED }
@Query(
"""
@@ -378,24 +359,23 @@ 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 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)
+ 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)
}
@Upsert suspend fun insert(reaction: ReactionEntity)
@@ -499,10 +479,9 @@ 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 fde388ce5..2e7f6c549 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,8 +17,9 @@
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
@@ -31,5 +32,6 @@ interface TracerouteNodePositionDao {
@Query("DELETE FROM traceroute_node_position WHERE log_uuid = :logUuid")
suspend fun deleteByLogUuid(logUuid: String)
- @Upsert suspend fun insertAll(entities: List)
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ 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 fed88eef9..13d10193c 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,7 +118,6 @@ 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 d01171751..16b1e66e4 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,9 +74,6 @@ 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(
@@ -101,12 +98,9 @@ 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 71a7fef1c..6da9df5b7 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,42 +271,6 @@ 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 7d46cc831..903dde119 100644
--- a/core/datastore/build.gradle.kts
+++ b/core/datastore/build.gradle.kts
@@ -24,11 +24,7 @@ plugins {
kotlin {
jvm()
- android {
- namespace = "org.meshtastic.core.datastore"
- androidResources.enable = false
- withHostTest {}
- }
+ android { namespace = "org.meshtastic.core.datastore" }
sourceSets {
commonMain.dependencies {
@@ -40,11 +36,5 @@ 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 9de792a84..94ef1c605 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(DATASTORE_SCOPE) scope: CoroutineScope,
+ @Named("DataStoreScope") scope: CoroutineScope,
): DataStore = PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations =
@@ -66,7 +66,7 @@ class LocalConfigDataStoreModule {
@Named("CoreLocalConfigDataStore")
fun provideLocalConfigDataStore(
context: Context,
- @Named(DATASTORE_SCOPE) scope: CoroutineScope,
+ @Named("DataStoreScope") scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -85,7 +85,7 @@ class ModuleConfigDataStoreModule {
@Named("CoreModuleConfigDataStore")
fun provideModuleConfigDataStore(
context: Context,
- @Named(DATASTORE_SCOPE) scope: CoroutineScope,
+ @Named("DataStoreScope") scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -104,7 +104,7 @@ class ChannelSetDataStoreModule {
@Named("CoreChannelSetDataStore")
fun provideChannelSetDataStore(
context: Context,
- @Named(DATASTORE_SCOPE) scope: CoroutineScope,
+ @Named("DataStoreScope") scope: CoroutineScope,
): DataStore = DataStoreFactory.create(
storage =
OkioStorage(
@@ -123,7 +123,7 @@ class LocalStatsDataStoreModule {
@Named("CoreLocalStatsDataStore")
fun provideLocalStatsDataStore(
context: Context,
- @Named(DATASTORE_SCOPE) scope: CoroutineScope,
+ @Named("DataStoreScope") 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 3cb3cabe8..aa81f1ac6 100644
--- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
+++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreModule.kt
@@ -24,17 +24,10 @@ 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(DATASTORE_SCOPE)
+ @Named("DataStoreScope")
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
deleted file mode 100644
index 3acd29cb9..000000000
--- a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt
+++ /dev/null
@@ -1,286 +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.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/model/build.gradle.kts b/core/model/build.gradle.kts
index 92374706a..4e01fc223 100644
--- a/core/model/build.gradle.kts
+++ b/core/model/build.gradle.kts
@@ -58,8 +58,6 @@ 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
new file mode 100644
index 000000000..5f75d687d
--- /dev/null
+++ b/core/model/consumer-rules.pro
@@ -0,0 +1,2 @@
+-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
new file mode 100644
index 000000000..473e482e2
--- /dev/null
+++ b/core/model/src/androidMain/kotlin/org/meshtastic/core/model/util/AndroidDateTimeUtils.kt
@@ -0,0 +1,51 @@
+/*
+ * 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 99debb5ab..13b0789de 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,13 +17,12 @@
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 = this.toKmpUri()
+fun Uri.toCommonUri(): CommonUri = CommonUri.parse(this.toString())
/** 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 4e02ae2a7..65096604f 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)
- /** Ability to request neighbor information from other nodes. Gated to [UNRELEASED] until working reliably. */
+ /** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
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.8.0. */
- val supportsStatusMessage = atLeast(V2_8_0)
+ /** Support for Status Message module. Supported since firmware v2.7.17. */
+ val supportsStatusMessage = atLeast(V2_7_17)
/** 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 c8bbdadb5..0af5a0efd 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,16 +16,24 @@
*/
package org.meshtastic.core.model
-sealed interface ConnectionState {
+sealed class 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
+ data object DeviceSleep : ConnectionState()
+
+ fun isConnected() = this == Connected
+
+ fun isConnecting() = this == Connecting
+
+ fun isDisconnected() = this == Disconnected
+
+ fun isDeviceSleep() = this == DeviceSleep
}
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
deleted file mode 100644
index 4d3bfca10..000000000
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttConnectionState.kt
+++ /dev/null
@@ -1,56 +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
-
-/**
- * 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
deleted file mode 100644
index e3cb7c77a..000000000
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttProbeStatus.kt
+++ /dev/null
@@ -1,52 +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.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 70dea8574..13eccae2a 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,9 +19,10 @@ 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
@@ -142,26 +143,34 @@ data class Node(
private fun EnvironmentMetrics.getDisplayStrings(isFahrenheit: Boolean): List {
val temp =
if ((temperature ?: 0f) != 0f) {
- MetricFormatter.temperature(temperature ?: 0f, isFahrenheit)
+ if (isFahrenheit) {
+ formatString("%.1f°F", celsiusToFahrenheit(temperature ?: 0f))
+ } else {
+ formatString("%.1f°C", temperature)
+ }
} else {
null
}
- val humidity = if ((relative_humidity ?: 0f) != 0f) MetricFormatter.humidity(relative_humidity ?: 0f) else null
+ val humidity = if ((relative_humidity ?: 0f) != 0f) formatString("%.0f%%", relative_humidity) else null
val soilTemperatureStr =
if ((soil_temperature ?: 0f) != 0f) {
- MetricFormatter.temperature(soil_temperature ?: 0f, isFahrenheit)
+ if (isFahrenheit) {
+ formatString("%.1f°F", celsiusToFahrenheit(soil_temperature ?: 0f))
+ } else {
+ formatString("%.1f°C", soil_temperature)
+ }
} else {
null
}
val soilMoistureRange = 0..100
val soilMoisture =
if ((soil_moisture ?: Int.MIN_VALUE) in soilMoistureRange && (soil_temperature ?: 0f) != 0f) {
- MetricFormatter.percent(soil_moisture ?: 0)
+ formatString("%d%%", soil_moisture)
} 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 voltage = if ((this.voltage ?: 0f) != 0f) formatString("%.2fV", this.voltage) else null
+ val current = if ((current ?: 0f) != 0f) formatString("%.1fmA", current) else null
val iaq = if ((iaq ?: 0) != 0) "IAQ: $iaq" else null
return listOfNotNull(
@@ -190,12 +199,9 @@ 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 84994e628..54797eb75 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,16 +28,7 @@ import org.meshtastic.proto.ClientNotification
*/
@Suppress("TooManyFunctions")
interface RadioController {
- /**
- * 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.
- */
+ /** Reactive connection state of the radio. */
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 dfe70fd92..6f27bb0e6 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,11 +18,8 @@
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
/**
@@ -35,7 +32,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', ' ')
@@ -51,24 +48,6 @@ 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 ebdcc0f5e..ca035a7fd 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,27 +16,7 @@
*/
package org.meshtastic.core.model.util
-import okio.ByteString.Companion.toByteString
-
/** Computes SFPP (Store-Forward-Plus-Plus) message hashes for deduplication. */
-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)
- }
+expect object SfppHasher {
+ fun computeMessageHash(encryptedPayload: ByteArray, to: Int, from: Int, id: Int): ByteArray
}
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 4b3f5d149..b2e175382 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt
@@ -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 365a47c61..ecaf88db6 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_8_0() {
- assertFalse(caps("2.7.21").supportsStatusMessage)
- assertTrue(caps("2.8.0").supportsStatusMessage)
+ fun supportsStatusMessage_requires_V2_7_17() {
+ assertFalse(caps("2.7.16").supportsStatusMessage)
+ assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
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
deleted file mode 100644
index 917414e3d..000000000
--- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SfppHasherTest.kt
+++ /dev/null
@@ -1,87 +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.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 d17abd4a3..7545a00a7 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,3 +20,7 @@ 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
new file mode 100644
index 000000000..b1c25110b
--- /dev/null
+++ b/core/model/src/jvmAndroidMain/kotlin/org/meshtastic/core/model/util/SfppHasher.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 858229b69..99a0802ae 100644
--- a/core/navigation/build.gradle.kts
+++ b/core/navigation/build.gradle.kts
@@ -32,7 +32,5 @@ 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 c36375356..c4d3ac044 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,35 +111,4 @@ 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 f2fb85d7f..1c0d14a01 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)
- api(libs.meshtastic.mqtt.client)
+ implementation(libs.kmqtt.client)
+ implementation(libs.kmqtt.common)
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 426c6700b..28eb2175d 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,7 +17,6 @@
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
@@ -26,23 +25,21 @@ 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 creating platform-specific connections (TCP, USB/Serial, Mock, NOP) directly in [createPlatformTransport].
+ * while delegating legacy platform-specific connections (like USB/Serial, TCP, and Mocks) to the Android-specific
+ * [InterfaceFactory].
*/
@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,
@@ -51,50 +48,13 @@ class AndroidRadioTransportFactory(
override val supportedDeviceTypes: List = listOf(DeviceType.BLE, DeviceType.TCP, DeviceType.USB)
- override fun isMockTransport(): Boolean =
+ override fun isMockInterface(): Boolean =
buildConfigProvider.isDebug || Settings.System.getString(context.contentResolver, "firebase.test.lab") == "true"
- 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 isPlatformAddressValid(address: String): Boolean = interfaceFactory.value.addressValid(address)
override fun createPlatformTransport(address: String, service: RadioInterfaceService): RadioTransport {
- 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")
- }
+ // Fallback to legacy factory for Serial, Mocks, and NOPs
+ return interfaceFactory.value.createInterface(address, service)
}
}
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
new file mode 100644
index 000000000..f33cedfae
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/InterfaceFactory.kt
@@ -0,0 +1,66 @@
+/*
+ * 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/SerialRadioTransport.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt
similarity index 76%
rename from core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
rename to core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt
index 0f7985276..e57c4a446 100644
--- a/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialRadioTransport.kt
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterface.kt
@@ -17,39 +17,40 @@
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.network.transport.HeartbeatSender
-import org.meshtastic.core.repository.RadioTransportCallback
+import org.meshtastic.core.repository.RadioInterfaceService
+import org.meshtastic.proto.Heartbeat
+import org.meshtastic.proto.ToRadio
import java.util.concurrent.atomic.AtomicReference
-/** An Android USB/serial [RadioTransport] implementation. */
-class SerialRadioTransport(
- callback: RadioTransportCallback,
- scope: CoroutineScope,
+/** An interface that assumes we are talking to a meshtastic device via USB serial */
+class SerialInterface(
+ service: RadioInterfaceService,
private val usbRepository: UsbRepository,
private val address: String,
-) : StreamTransport(callback, scope) {
+) : StreamInterface(service) {
private var connRef = AtomicReference()
- private val heartbeatSender = HeartbeatSender(sendToRadio = ::handleSendToRadio, logTag = "Serial[$address]")
-
- override fun start() {
+ init {
connect()
}
- override fun onDeviceDisconnect(waitForStopped: Boolean, isPermanent: Boolean) {
+ override fun onDeviceDisconnect(waitForStopped: Boolean) {
connRef.get()?.close(waitForStopped)
- super.onDeviceDisconnect(waitForStopped, isPermanent)
+ super.onDeviceDisconnect(waitForStopped)
}
override fun connect() {
val deviceMap = usbRepository.serialDevices.value
- val device = deviceMap[address] ?: deviceMap.values.firstOrNull()
+ val device =
+ if (deviceMap.containsKey(address)) {
+ deviceMap[address]!!
+ } else {
+ deviceMap.map { (_, driver) -> driver }.firstOrNull()
+ }
if (device == null) {
Logger.e { "[$address] Serial device not found at address" }
} else {
@@ -108,10 +109,7 @@ class SerialRadioTransport(
"Uptime: ${uptime}ms, " +
"Packets RX: $packetsReceived ($bytesReceived bytes)"
}
- // 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)
+ onDeviceDisconnect(false)
}
},
)
@@ -123,9 +121,14 @@ class SerialRadioTransport(
}
override fun keepAlive() {
- // 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() }
+ // 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())
}
override fun sendBytes(p: ByteArray) {
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
new file mode 100644
index 000000000..f8c53313b
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceFactory.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.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
new file mode 100644
index 000000000..8597fd060
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/SerialInterfaceSpec.kt
@@ -0,0 +1,51 @@
+/*
+ * 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/TCPInterfaceFactory.kt b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt
new file mode 100644
index 000000000..003294448
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceFactory.kt
@@ -0,0 +1,27 @@
+/*
+ * 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
new file mode 100644
index 000000000..2539bc13c
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/radio/TCPInterfaceSpec.kt
@@ -0,0 +1,27 @@
+/*
+ * 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 d8b14be03..b2ccf6545 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,11 +87,6 @@ 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
new file mode 100644
index 000000000..720d2a522
--- /dev/null
+++ b/core/network/src/androidMain/kotlin/org/meshtastic/core/network/repository/TrustAllX509TrustManager.kt
@@ -0,0 +1,31 @@
+/*
+ * 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 c5080ec14..b4773dff3 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,7 +54,9 @@ class UsbRepository(
_serialDevices
.mapLatest { serialDevices ->
val serialProber = usbSerialProberLazy.value
- buildMap { serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { put(k, it) } } }
+ buildMap {
+ serialDevices.forEach { (k, v) -> serialProber.probeDevice(v)?.let { driver -> put(k, driver) } }
+ }
}
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
@@ -81,8 +83,6 @@ class UsbRepository(
processLifecycle.coroutineScope.launch(dispatchers.default) { refreshStateInternal() }
}
- private suspend fun refreshStateInternal() = withContext(dispatchers.default) {
- val devices = usbManagerLazy.value?.deviceList ?: emptyMap()
- _serialDevices.emit(devices)
- }
+ private suspend fun refreshStateInternal() =
+ withContext(dispatchers.default) { _serialDevices.emit(usbManagerLazy.value?.deviceList ?: emptyMap()) }
}
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
deleted file mode 100644
index 87c317024..000000000
--- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/HttpClientDefaults.kt
+++ /dev/null
@@ -1,34 +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