Merge remote-tracking branch 'origin/main' into screen-mirror-poc

# Conflicts:
#	core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/FromRadioPacketHandlerImplTest.kt
#	gradle/libs.versions.toml
This commit is contained in:
James Rich committed 2026-09-19 17:02:26 -05:00
commit 720c4c246f
1432 files changed
+42864 -15136

No files matched your search

+4 -4
View File
@@ -1,12 +1,12 @@
---
name: pr
description: Push the current branch and open a draft PR for Meshtastic-Android the repo way — baseline verified first, body drafted per .github/copilot-pull-request-instructions.md (WHY-first, categorized changes), screenshots embedded via commit-pinned raw URLs. Use whenever work is ready to go up as a PR.
description: Push the current branch and open a draft PR for Meshtastic-Android the repo way — baseline verified first, body drafted per CONTRIBUTING.md (WHY-first, categorized changes, screenshots for UI work). Use whenever work is ready to go up as a PR.
disable-model-invocation: true
---
# pr
Opens a PR the way this repo expects. The SOP lives in `.github/copilot-pull-request-instructions.md` — **read it now and follow it**; this skill only adds the steps around it.
Opens a PR the way this repo expects. The SOP lives in `CONTRIBUTING.md` → **Pull Requests** — **read it now and follow it**; this skill only adds the steps around it.
## 1. Pre-flight
- Target branch is `main` unless told otherwise.
@@ -15,9 +15,9 @@ Opens a PR the way this repo expects. The SOP lives in `.github/copilot-pull-req
- `git status`: nothing unintended staged. Never commit `.agent_memory/`. If `docs/assets/screenshots/*.png` are dirty from a test run (not an intentional UI change), restore them: `git checkout -- docs/assets/screenshots`.
## 2. Body
Draft per the SOP file (WHY-first summary, then changes under the 🌟 Features / 🛠️ Improvements / 🐛 Bug Fixes / 🧹 Chores categories that apply, **Testing Performed** section when tests were added/changed).
Draft per `CONTRIBUTING.md` → Pull Requests → **Writing the description** (WHY-first summary, then changes under the 🌟 New Features / 🛠️ Refactoring & Architecture / 🐛 Bug Fixes / 🧹 Chores categories that apply, **Testing Performed** section when tests were added/changed).
**Screenshots** (UI changes want them): commit the PNGs on the branch, push, then embed with commit-pinned raw URLs so they render in the PR body immediately:
**Screenshots** (UI changes want them): `CONTRIBUTING.md` → Pull Requests → Screenshots owns the policy, and it allows either a GitHub-hosted upload or a committed image. When committing one, push it first and embed it by commit-pinned raw URL so it renders immediately:
```
https://raw.githubusercontent.com/<owner>/<repo>/<full-commit-sha>/<path/to/img.png>
```
+1 -1
View File
@@ -81,7 +81,7 @@ New proto versions usually add fields/messages the app doesn't consume yet. Diff
## PR + guardrails
- Write the PR per `.github/copilot-pull-request-instructions.md`: WHY-first; 🛠️ (or 🌟 if it unlocks a user-facing feature); link the upstream release notes (real URL only); "Testing Performed" = the gradle-runner run including `allTests`.
- Write the PR per `CONTRIBUTING.md` → Pull Requests: WHY-first; 🧹 Chores for a routine catalog bump, since that is where `CONTRIBUTING.md` puts dependencies — 🛠️ only when the bump carries call-site adaptations of its own, 🌟 if it unlocks a user-facing feature; link the upstream release notes (real URL only); "Testing Performed" = the gradle-runner run including `allTests`.
- Never hand-edit or vendor generated proto — this repo consumes the Maven artifact only.
- Keep the change minimal: a bump PR is a catalog edit + the force block (Mode B) + necessary call-site adaptations, not a feature.
- Branch off `main` unless told otherwise.
+19 -4
View File
@@ -13,10 +13,14 @@ reviews:
auto_review:
enabled: true
drafts: false
# Review once when the PR goes ready, then on demand via `@coderabbitai review`.
# Re-reviewing every push turned 6-commit PRs into 8 review rounds, because each
# fix commit reopened a full pass. Batch the fixes, push, then ask for one re-review.
auto_incremental_review: false
# Review each push incrementally. With this off, a push got no review at all and the
# only way to re-review was `@coderabbitai full review`, which re-reads the whole diff
# and surfaces new findings on unchanged lines every round.
auto_incremental_review: true
# Never pause on our own. The default (5) stops automatic review after five
# reviewed commits and says so only inside the pinned summary comment — the
# check still goes green — so a long PR silently stops being reviewed.
auto_pause_after_reviewed_commits: 0
ignore_usernames:
- renovate
- renovate[bot]
@@ -121,6 +125,17 @@ reviews:
One problem per comment. Cite the exact line, symbol, or condition. Give a fix direction — a
snippet when the fix is not obvious. Never restate a finding already raised in an existing
review thread.
Scope. If a finding needs work beyond what this diff set out to do — a schema migration, a new
column, a change to a subsystem the PR only touches incidentally — say so and recommend a
follow-up issue instead of a change request. Prefer the smallest correct fix over the most
thorough one on a PR that is already several rounds deep.
Settled decisions. Do not re-raise a concern that an existing thread on this PR already resolved,
and do not ask for a change to be reverted that was made in response to an earlier finding of
yours without saying that is what you are proposing and why the trade-off changed.
- path: "**/commonMain/**"
instructions: >
KMP common code. Flag any import of java.* or android.* — these break non-Android targets. Expect KMP equivalents instead (Okio, kotlinx Mutex/atomicfu, NumberFormatter.format() for floats).
+10
View File
@@ -0,0 +1,10 @@
# actionlint validates `runs-on` against a built-in list of GitHub-hosted labels, and
# the pin in pull-request.yml (1.7.12) predates ubuntu-26.04. Both labels below are
# real GitHub-hosted runners, not self-hosted; this key is simply actionlint's only
# mechanism for teaching it a label it does not yet know.
#
# Remove once the pinned actionlint recognises them on its own.
self-hosted-runner:
labels:
- ubuntu-26.04
- ubuntu-26.04-arm
+5 -3
View File
@@ -12,10 +12,12 @@
org.gradle.daemon=false
# ── Memory ────────────────────────────────────────────────────────────
# Standard GitHub runners have 7 GB RAM. Keep Gradle + Kotlin daemon
# within budget (4g Gradle + 2g Kotlin daemon + 1g OS/tooling headroom).
# Public-repo ubuntu-24.04 runners have 16 GB RAM. Keep Gradle + Kotlin daemon
# within budget (4g Gradle + 6g Kotlin daemon, leaving room for lint and K/N).
# Only kotlin.daemon.jvmargs is read from a properties file; kotlin.daemon.jvm.options
# is a system property. Unset, the daemon inherits org.gradle.jvmargs' 4g and OOMs.
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
kotlin.daemon.jvm.options=-Xmx2g -XX:+UseParallelGC
kotlin.daemon.jvmargs=-Xmx6g -XX:+UseG1GC
# ── Parallelism ───────────────────────────────────────────────────────
org.gradle.parallel=true
@@ -1,25 +0,0 @@
# GitHub Copilot Pull Request Instructions
<role>
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.
</role>
<instructions>
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. **Screenshots for UI changes:** If the change affects the UI (Compose composables, layouts, theming, navigation, or anything under `feature/**` / `core/ui/**`), add a **Screenshots** section when real images are available — for example generated `:screenshot-tests` reference PNGs committed in the PR, or images captured from a device/emulator. Prefer a **Before / After** table for visual changes and fixes:
| Before | After |
|--------|-------|
| <img src="<url>" width="300"/> | <img src="<url>" width="300"/> |
Reference committed images with a stable **commit-SHA** raw URL (`https://raw.githubusercontent.com/<owner>/<repo>/<sha>/<path>`, URL-encoding spaces as `%20`) so the links survive branch deletion, or use a GitHub-uploaded attachment. Only embed images that actually exist.
7. **No "Magic" Text:** Never invent URLs or insert fake/placeholder images. If no real screenshot is available for a UI change, leave the template's HTML image comment block intact so the author can add one — do not fabricate.
</instructions>
-12
View File
@@ -1,12 +0,0 @@
{
"lspServers": {
"kotlin": {
"command": "kotlin-language-server",
"args": [],
"fileExtensions": {
".kt": "kotlin",
".kts": "kotlin"
}
}
}
}
@@ -41,7 +41,7 @@ concurrency:
jobs:
determine-tags:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
tag_to_process: ${{ steps.calculate_tags.outputs.tag_to_process }}
@@ -182,7 +182,7 @@ jobs:
cleanup-on-failure:
needs: [determine-tags, call-release-workflow]
if: ${{ (failure() || cancelled()) && !inputs.dry_run && inputs.channel == 'internal' }}
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
steps:
- name: Checkout code
@@ -21,7 +21,7 @@ jobs:
submit-dependency-graph:
# failed runs may have partial graphs; skip
if: github.repository == 'meshtastic/Meshtastic-Android' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
steps:
# skipped android-check (docs-only/bot PRs) uploads nothing — don't fail red on that
+3 -3
View File
@@ -40,7 +40,7 @@ concurrency:
jobs:
publish:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 45
steps:
- name: Checkout
@@ -56,9 +56,9 @@ jobs:
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.6'
ruby-version: '4.0.7'
bundler-cache: true
working-directory: docs
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
# Lightweight, but it fetches a Node toolchain, so it takes the ARM tier rather than
# ubuntu-slim. No workflow currently runs Node; ubuntu-slim's suitability for setup-node
# is untested and this is not the place to find out.
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
steps:
+3 -3
View File
@@ -41,7 +41,7 @@ concurrency:
jobs:
publish:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 45
steps:
- name: Checkout
@@ -86,9 +86,9 @@ jobs:
develocity_access_key: ${{ secrets.DEVELOCITY_ACCESS_KEY }}
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.6'
ruby-version: '4.0.7'
bundler-cache: true
working-directory: docs
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
# Android APKs may still have built fine — attempt the snapshot regardless. If the
# APK build itself failed, the artifact download below fails and this job goes red.
if: github.repository == 'meshtastic/Meshtastic-Android' && !cancelled()
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
# ~1.5 GB of assets, uploaded one at a time with up to 3 attempts each (see "Upload
# snapshot assets"). Sized for the worst case that still succeeds -- every asset failing
# twice before landing -- which is 45 uploads plus 7m30s of backoff. The 2026-08-26
+11 -7
View File
@@ -19,12 +19,12 @@ jobs:
# new merge group but does NOT cancel the workflow runs of the destroyed one. Those stale
# runs sit queued/running and starve the runner pool. Cancel any older merge-queue run
# for the same PR — only the newest merge group per PR is ever valid.
# No checkout, no toolchain — just gh api + jq. ubuntu-slim starts sooner than a VM, which
# matters most here: every second before this runs is a second the superseded run keeps a slot.
# No checkout, no toolchain — just gh api + jq. Runs on the same label as check-changes:
# ubuntu-slim is a separate, smaller pool, and a gate job waiting on it holds the whole queue.
cancel-superseded:
name: Cancel Superseded Queue Runs
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-slim
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
permissions:
actions: write
@@ -44,7 +44,10 @@ jobs:
--jq ".workflow_runs[] | select(.head_branch | startswith(\"$PR_PREFIX\")) | select(.id < ${{ github.run_id }}) | .id"
done | sort -u | while read -r run_id; do
echo "Cancelling superseded run $run_id"
# A graceful cancel is delivered to the runner, so a run still waiting for one
# never receives it. force-cancel ends the run at the API regardless.
gh run cancel "$run_id" --repo "${{ github.repository }}" || true
gh api -X POST "repos/${{ github.repository }}/actions/runs/${run_id}/force-cancel" || true
done
# Docs-only queue entries (changelog updates, markdown fixes) cannot affect the build;
@@ -53,7 +56,7 @@ jobs:
check-changes:
name: Check Changes
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
outputs:
android: ${{ steps.filter.outputs.android }}
@@ -96,11 +99,12 @@ jobs:
upload_artifacts: false
secrets: inherit
# Pure gate job: no checkout, no toolchain, just reads `needs` results. ubuntu-slim is a
# container-backed single-CPU runner that starts faster than a VM (15-min job cap, x64 only).
# Pure gate job: no checkout, no toolchain, just reads `needs` results. It is the required
# check, so it runs on the label the rest of the workflow already gets slots on, not on
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-slim
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
permissions: {}
needs:
+61
View File
@@ -0,0 +1,61 @@
name: Play Store Listing
# Uploads fastlane/metadata/android - every locale's title, descriptions,
# feature graphic, icon and screenshots - to the Google Play listing. Manual
# only: a listing commit is a Play submission, and a submission sent for review
# cancels and restarts any release review in flight. Nothing here touches a
# build or a track.
#
# The default is a dry run. Play validates the whole edit (unknown locales,
# overlength text, bad image dimensions) and discards it; tick `publish` to
# commit the edit instead. Even then it is held as "changes not sent for
# review" - send it from the Play Console when no release review is running.
on:
workflow_dispatch:
inputs:
publish:
description: "Commit the listing to Play (unticked = validate only, nothing changes)"
type: boolean
default: false
permissions:
contents: read
# Two edits on the same listing race; the later one wins and the earlier one
# is silently discarded.
concurrency:
group: play-listing
cancel-in-progress: false
jobs:
upload:
runs-on: ubuntu-slim
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Fails before any credential is written: a bad locale directory is
# rejected by the Play API mid-upload, after earlier locales have already
# been written, and an overlength string is rejected the same way.
- name: Check store metadata
run: python3 scripts/check-store-metadata.py
- name: Set up Ruby
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.7'
bundler-cache: true
- name: Decode Play Store credentials
run: echo '${{ secrets.GOOGLE_PLAY_JSON_KEY }}' > fastlane/play-store-credentials.json
- name: Upload listing
env:
VALIDATE_ONLY: ${{ inputs.publish && 'false' || 'true' }}
run: bundle exec fastlane play_listing validate_only:"$VALIDATE_ONLY"
- name: Clean up credentials
if: always()
run: rm -f fastlane/play-store-credentials.json
+6 -6
View File
@@ -72,7 +72,7 @@ permissions:
jobs:
prepare-build-info:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
@@ -115,7 +115,7 @@ jobs:
shell: bash
promote-release:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 30
needs: [ prepare-build-info ]
env:
@@ -129,9 +129,9 @@ jobs:
ref: ${{ inputs.commit_sha || inputs.tag_name }}
- name: Set up Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.6'
ruby-version: '4.0.7'
bundler-cache: true
- name: Decode Play Store credentials
@@ -206,7 +206,7 @@ jobs:
run: rm -f fastlane/play-store-credentials.json
update-github-release:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
needs: [ prepare-build-info, promote-release ]
# actions: write is scoped here — only this job's publish-workflow
@@ -408,7 +408,7 @@ jobs:
# promoted to homebrew/cask, replace the tap PR with `brew bump-cask-pr`.
update-homebrew-cask:
if: ${{ inputs.channel == 'production' }}
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 15
needs: [ update-github-release ]
steps:
+31 -7
View File
@@ -18,7 +18,7 @@ jobs:
# compute, are the scarce resource during queue bursts).
check-changes:
if: github.repository == 'meshtastic/Meshtastic-Android' && !( github.head_ref == 'scheduled-updates' || github.head_ref == 'l10n_main' )
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
android: ${{ steps.filter.outputs.android }}
@@ -88,6 +88,7 @@ jobs:
- 'feature/**'
- 'screenshot-tests/**'
- 'docs-screenshots/**'
- 'marketing-screenshots/**'
# Shared build infrastructure
- 'build-logic/**'
- 'config/**'
@@ -170,6 +171,7 @@ jobs:
':baselineprofile',
':core:konsist',
':docs-screenshots',
':marketing-screenshots',
':screenshot-tests',
}
@@ -232,6 +234,7 @@ jobs:
# and remove it from this list.
no_tests_yet = {
':core:di',
':marketing-screenshots', # store-listing screenshot generator, a JVM program
':core:nfc',
':core:resources',
}
@@ -277,7 +280,7 @@ jobs:
check-metadata:
name: Check Store Metadata
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
permissions:
contents: read
@@ -303,8 +306,16 @@ jobs:
run: ./scripts/verify-rb-selftest.sh
- name: Self-test verify-abi-parity
run: ./scripts/verify-abi-parity-selftest.sh
- name: Validate store listing metadata lengths
run: python3 scripts/check-metadata-length.py
# Locale codes, lengths and HTML for all three stores that read the tree. supply
# uploads every locale directory it finds and neither it nor the Crowdin CLI
# validates the names, so an unmapped code is only rejected by the Play API, partway
# through an upload; F-Droid and IzzyOnDroid read the tree straight from git.
- name: Validate store listing metadata
run: python3 scripts/check-store-metadata.py
# default.txt is rendered from metainfo.xml; a PR that edits one without the
# other ships a listing that no longer says what the release did.
- name: Check Play what's-new matches AppStream
run: python3 scripts/sync-play-changelog.py --check
# Compose Multiplatform does not strip Android-style \" / \' escapes, so a
# backslash written before a quote renders literally in the UI (PR #6357).
# Guards the English source strings; locale mirrors are cleaned upstream by
@@ -341,6 +352,18 @@ jobs:
exit 1
fi
# Flathub runs this exact command (flatpak-builder-lint checks/metainfo.py) and turns any
# non-zero exit into appstream-failed-validation at publish time. Nothing here ran it, so a
# malformed <description> reached Flathub before CI ever saw it. Catches a whitespace-only or
# empty-<p> description; a truly empty <description></description> and a release with no
# description at all both still validate, so this is hygiene, not a prose gate.
- name: Validate AppStream metainfo
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq appstream
appstreamcli validate --no-net \
desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# Coverage stays off for PRs to keep feedback fast (< 10 mins); mainline coverage comes
# from main-check. Desktop *compilation* is covered on every PR by :desktopApp:test in the
@@ -369,11 +392,12 @@ jobs:
secrets: inherit
# 3. WORKFLOW STATUS: Ensures required checks are satisfied
# Pure gate job: no checkout, no toolchain, just reads `needs` results. ubuntu-slim is a
# container-backed single-CPU runner that starts faster than a VM (15-min job cap, x64 only).
# Pure gate job: no checkout, no toolchain, just reads `needs` results. It is the required
# check, so it runs on the label the rest of the workflow already gets slots on, not on
# ubuntu-slim's separate pool: an aggregator queued behind slim blocks a finished build.
check-workflow-status:
name: Check Workflow Status
runs-on: ubuntu-slim
runs-on: ubuntu-26.04-arm
timeout-minutes: 5
permissions: {}
needs: [check-changes, check-metadata, validate-and-build]
+24 -9
View File
@@ -85,7 +85,7 @@ permissions:
jobs:
prepare-build-info:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 10
outputs:
APP_VERSION_NAME: ${{ steps.prep_version.outputs.APP_VERSION_NAME }}
@@ -127,7 +127,7 @@ jobs:
shell: bash
release-google:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 90
needs: [prepare-build-info]
steps:
@@ -168,9 +168,9 @@ jobs:
echo "$GOOGLE_PLAY_JSON_KEY" > ./fastlane/play-store-credentials.json
- name: Setup Fastlane
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.6'
ruby-version: '4.0.7'
bundler-cache: true
- name: Build and Deploy Google Play to Internal Track with Fastlane
@@ -211,7 +211,7 @@ jobs:
subject-path: androidApp/build/outputs/apk/google/release/*.apk
release-fdroid:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 90
needs: [prepare-build-info]
steps:
@@ -239,9 +239,9 @@ jobs:
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
- name: Setup Fastlane
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
with:
ruby-version: '4.0.6'
ruby-version: '4.0.7'
bundler-cache: true
- name: Build F-Droid with Fastlane
@@ -485,7 +485,7 @@ jobs:
release-flatpak-src:
if: ${{ inputs.build_flatpak_src }}
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 30
needs: [create-flatpak-src]
steps:
@@ -523,7 +523,7 @@ jobs:
github-release:
if: ${{ !cancelled() && !failure() }}
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
timeout-minutes: 15
needs:
- prepare-build-info
@@ -575,6 +575,21 @@ jobs:
${PREV:+-f previous_tag_name="$PREV"} \
--jq '.body' > release-notes.md
# The generated notes are a list of PR titles - accurate, and not what a user reads.
# metainfo.xml already carries one hand-written paragraph per version, required by
# pull-request.yml on the VERSION_NAME_BASE bump, so reuse it rather than asking for
# the same sentence twice. When it is missing the notes gain a rendered warning
# instead of silence, and every release here is created as a draft, so that warning
# is seen before anyone publishes.
- name: Prepend release highlights
run: |
python3 scripts/release-highlights.py \
desktopApp/packaging/linux/org.meshtastic.MeshtasticDesktop.metainfo.xml \
config.properties \
release-notes.md
echo "--- release notes ---"
cat release-notes.md
- name: Create or Update GitHub Release
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3
with:
+18 -15
View File
@@ -71,7 +71,7 @@ jobs:
# ── Lint & Static Analysis ──────────────────────────────────────────
lint-check:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -106,11 +106,11 @@ jobs:
install_jetbrains_jdk: 'true'
- name: Lint, Analysis & KMP Smoke Compile
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug kmpSmokeCompile -Pci=true --continue
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug -Pci=true --continue
# ── Screenshot Test Validation ──────────────────────────────────────
screenshot-check:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -159,7 +159,7 @@ jobs:
# Only runs in the merge queue (not PRs, to keep PR feedback fast; not main pushes,
# which are the same merge commit the queue just verified).
rb-check:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -197,13 +197,15 @@ jobs:
# These lists are hand-maintained; pull-request.yml's check-changes job
# guards them against drift (every module in settings.gradle.kts with test
# sources must appear here or be explicitly exempted).
# shard-core: remaining core:* KMP module tests (allTests)
# shard-core: remaining core:* KMP module tests (allTests), plus kmpSmokeCompile,
# which is the only iOS compile :core:di, :core:nfc and :core:resources
# get. It lived in lint-check until a cold build there blew the 30m budget.
# shard-feature: feature:* KMP module tests + :core:service
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp,
# core:barcode, feature:widget)
# + :core:database + :core:network
test-shards:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
permissions:
contents: read
pull-requests: write
@@ -230,6 +232,7 @@ jobs:
:core:takserver:allTests
:core:testing:allTests
:core:ui:allTests
kmpSmokeCompile
kover: >-
:core:ble:koverXmlReport
:core:common:koverXmlReport
@@ -349,7 +352,7 @@ jobs:
# which Codecov flag its coverage lands under.
- name: Upload test results to Codecov (core)
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -361,7 +364,7 @@ jobs:
- name: Upload test results to Codecov (feature)
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -373,7 +376,7 @@ jobs:
- name: Upload test results to Codecov (app)
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -385,7 +388,7 @@ jobs:
- name: Upload test results to Codecov (desktop)
if: ${{ !cancelled() }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -397,7 +400,7 @@ jobs:
- name: Upload coverage to Codecov (core)
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -408,7 +411,7 @@ jobs:
- name: Upload coverage to Codecov (feature)
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -419,7 +422,7 @@ jobs:
- name: Upload coverage to Codecov (app)
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -430,7 +433,7 @@ jobs:
- name: Upload coverage to Codecov (desktop)
if: ${{ !cancelled() && inputs.run_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
@@ -454,7 +457,7 @@ jobs:
# ── Android Build ────────────────────────────────────────────────────
# Also generates the dependency graph — assembling both flavors resolves the widest set.
android-check:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
# No permissions block on purpose: inherits the caller's. main grants contents: write
# (graph submit); PRs grant read (upload only).
timeout-minutes: 60
+1 -1
View File
@@ -16,7 +16,7 @@ concurrency:
jobs:
regenerate:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 90
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
+9 -8
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
update_assets:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
timeout-minutes: 30
if: github.repository == 'meshtastic/Meshtastic-Android'
permissions:
@@ -265,7 +265,7 @@ jobs:
fi
- name: Sync with Crowdin
uses: crowdin/github-action@0d5670f539973aea2f01abce61a8989934df0025 # v3
uses: crowdin/github-action@9af557de76d70c480f88065d336f445a362f402b # v3
with:
base_url: 'https://meshtastic.crowdin.com/api/v2'
config: 'crowdin.yml'
@@ -286,13 +286,14 @@ jobs:
- name: Fix file permissions
run: sudo chown -R "$USER:$USER" .
# Early warning for overlength store-listing translations just pulled from
# Crowdin. Non-blocking on purpose: a hard failure here would abort the job
# and stop the PR from being created. The hard gate is the check-metadata
# job in pull-request.yml, which runs against the PR this workflow opens.
- name: Check store metadata lengths
# Early warning for store-listing translations just pulled from Crowdin
# (overlength text, unmapped locale codes, forbidden HTML). Non-blocking on
# purpose: a hard failure here would abort the job and stop the PR from
# being created. The hard gate is the same script in pull-request.yml,
# which runs against the PR this workflow opens.
- name: Check store metadata
continue-on-error: true
run: python3 scripts/check-metadata-length.py
run: python3 scripts/check-store-metadata.py
- name: Build PR body
id: pr_body
+10 -7
View File
@@ -1,11 +1,9 @@
name: Update Changelog
# Manual only: promote.yml stamps the released section at release time on its
# own. Dispatch this to refresh the [Unreleased] section between releases.
on:
push:
branches:
- main
paths-ignore:
- 'CHANGELOG.md'
workflow_dispatch:
permissions:
contents: write
@@ -18,6 +16,9 @@ concurrency:
jobs:
update-changelog:
# The content comes from main whatever ref was dispatched; refuse the rest so a
# stale-branch dispatch cannot open a PR built against a different tip.
if: github.ref == 'refs/heads/main'
# ubuntu-slim: git + gh + coreutils + stdlib python3 is the whole toolchain here. Note this job checks out
# full history below and slim is single-CPU, so the clone is slower than on a VM — and
# slim's 15-minute platform cap is hard, matching the timeout already set here. A run that
@@ -37,6 +38,8 @@ jobs:
# Find the latest production tag (vX.Y.Z)
PROD=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1)
echo "prod=${PROD:-}" >> "$GITHUB_OUTPUT"
# The tip of the checked-out main; github.sha is the dispatched ref's tip.
echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [ -z "$PROD" ]; then
echo "No production tag found. Skipping."
@@ -83,7 +86,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.sha }}
HEAD_SHA: ${{ steps.tags.outputs.head }}
PROD_TAG: ${{ steps.tags.outputs.prod }}
INTERNAL_TAG: ${{ steps.tags.outputs.internal }}
CLOSED_TAG: ${{ steps.tags.outputs.closed }}
@@ -284,7 +287,7 @@ jobs:
else
gh pr create \
--title "docs: update CHANGELOG.md" \
--body "Automated changelog update from push to main." \
--body "Automated changelog refresh, dispatched from main." \
--head "$BRANCH" \
--base main \
--label "automation" \
+27 -9
View File
@@ -1,13 +1,24 @@
name: Verify Flatpak Offline Build
on:
# Changing the generator or the manifest is the one case worth gating pre-merge:
# break it and every later run is red, with nothing else covering it.
pull_request:
branches: [ main ]
paths:
- 'scripts/verify-flatpak/**'
- '.github/workflows/verify-flatpak.yml'
# The dependency surface the manifest captures is verified post-merge. This is not a
# required check and never ran in the merge queue, so per-PR it cost ~2 runner slots
# for a signal that blocks nothing; post-merge still catches a break within one merge,
# and `main` itself was previously never verified at all.
push:
branches: [ main ]
paths:
- 'scripts/verify-flatpak/**'
- '.github/workflows/verify-flatpak.yml'
- 'build.gradle.kts'
- 'settings.gradle.kts'
- '.github/workflows/verify-flatpak.yml'
# The desktop module's build config shapes the uber jar the flatpak wraps.
- 'desktopApp/**'
# The offline manifest pins the Gradle distribution independently of the wrapper —
@@ -15,17 +26,22 @@ on:
- 'gradle/wrapper/**'
# build.gradle.kts reads compose-multiplatform from the catalog (#6911), so a
# catalog-only bump changes the manifest's platform URLs. Deliberately the whole
# file and not a key filter: nearly every Renovate PR lands here so this over-runs,
# but a filter naming today's keys goes stale silently the moment the manifest
# reads another one — which is the failure #6911 existed to remove.
# file and not a key filter: a filter naming today's keys goes stale silently the
# moment the manifest reads another one — the failure #6911 existed to remove.
- 'gradle/libs.versions.toml'
# Drift no path filter can see: a Flathub runtime bump, or an upstream artifact that
# moved or vanished. Nothing in this repo changes, so no other trigger would fire.
schedule:
- cron: '0 4 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: flatpak-verify-${{ github.event.pull_request.number || github.ref }}
# Keyed on the event as well: a nightly and a push to main share github.ref, and
# without it cancel-in-progress lets one kill the other.
group: flatpak-verify-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -34,6 +50,8 @@ jobs:
# reusable-check.yml documents avoiding. Each leg now captures sources on its own arch.
verify-flatpak:
name: Verify Flatpak Offline Build (${{ matrix.arch }})
# Matches the other scheduled workflows: without it every fork runs the nightly.
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ${{ matrix.arch == 'aarch64' && 'ubuntu-24.04-arm' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
@@ -70,7 +88,7 @@ jobs:
- uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6
with:
distribution: temurin
java-version: 25
java-version: 25.0.4+101.0.LTS
token: ${{ github.token }}
# JBR resolves its version list via the GitHub API — unauthenticated requests
@@ -141,9 +159,9 @@ jobs:
echo "### ✅ Offline Flatpak build succeeded (${{ matrix.arch }})" >> "$GITHUB_STEP_SUMMARY"
# The build succeeding IS the verification; bundling costs ~2 min per arch for an
# artifact nobody installs from a PR. Kept for dispatch, where a tester wants it.
# artifact nobody installs from CI. Dispatch only, where a tester asked for it.
- name: Export .flatpak bundle
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch'
working-directory: ${{ runner.temp }}/org.meshtastic.MeshtasticDesktop
env:
ARCH: ${{ matrix.arch }}
@@ -153,7 +171,7 @@ jobs:
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: github.event_name != 'pull_request'
if: github.event_name == 'workflow_dispatch'
with:
name: meshtastic-desktop-flatpak-${{ matrix.arch }}
path: ${{ runner.temp }}/org.meshtastic.MeshtasticDesktop/org.meshtastic.MeshtasticDesktop.${{ matrix.arch }}.flatpak
+1 -1
View File
@@ -1 +1 @@
4.0.6
4.0.7
+52 -1
View File
@@ -72,6 +72,7 @@ altitude
always_point_north
ambient_lighting
ambient_lighting_config
ammonium
analytics_notice
analytics_okay
analytics_platforms
@@ -107,6 +108,8 @@ aqi_unhealthy
aqi_unhealthy_sensitive
aqi_value_with_severity
aqi_very_unhealthy
archived_channel
archived_channel_read_only
are_you_sure
are_you_sure_change_default
audio
@@ -126,6 +129,7 @@ bandwidth_unsupported_summary
baro_pressure
battery
battery_ina_2xx_i2c_address
biochemical_oxygen_demand
ble_devices
ble_rssi_threshold_defaults_to_80
ble_scan_needs_location_services
@@ -196,6 +200,7 @@ channel_name
channel_url
channel_utilization
channels
chemical_oxygen_demand
### CHIRPY ###
chirpy
chirpy_assistant_title
@@ -452,6 +457,7 @@ discovery_restoring_preset
discovery_scan_complete
discovery_scan_failed
discovery_scan_history
discovery_scan_in_progress
discovery_scan_incomplete
discovery_scan_progress
discovery_scan_summary
@@ -503,6 +509,7 @@ display_config
display_mode
display_time_in_12h_format
display_units
dissolved_oxygen
### DISTANCE ###
distance
distance_filters
@@ -534,6 +541,9 @@ doc_keywords_telemetry
doc_keywords_translate
doc_keywords_units
doc_keywords_widget
doc_loading
doc_no_documentation
doc_no_results
doc_search_placeholder
doc_section_developer
doc_section_user
@@ -574,6 +584,7 @@ echo_enabled
edit
edit_custom_tile_source
eight_hours
electrical_conductivity
elevation_suffix
### EMOJI ###
emoji_category_activities
@@ -707,6 +718,7 @@ firmware_update_filename_unavailable
firmware_update_flashing
firmware_update_hang_tight
firmware_update_hash_rejected
firmware_update_in_progress
firmware_update_invalid_local_file_detail
firmware_update_keep_device_close
firmware_update_latest
@@ -915,6 +927,8 @@ led_heartbeat
led_state
legacy_admin_channel
library_count
license
license_notice
### LICENSED ###
licensed_amateur_radio
licensed_amateur_radio_text
@@ -922,6 +936,8 @@ licensed_mode_enable_confirm
licensed_mode_enable_title
licensed_mode_enable_warning
licensed_mode_enable_warning_signed
lightning_distance
lightning_strikes_1h
### LOAD ###
load
load_15_min
@@ -1270,6 +1286,8 @@ new_node_seen
new_nodes
next
nfc_disabled
nitrate
nitrogen
### NO ###
no_bluetooth_devices_hint
no_bluetooth_devices_seen
@@ -1299,7 +1317,9 @@ node_filter_exclude_unheard
node_filter_ignored
node_filter_include_unknown
node_filter_only_direct
node_filter_only_encrypted
node_filter_only_online
node_filter_only_signed
node_filter_placeholder
node_filter_show_ignored
node_filter_title
@@ -1409,6 +1429,7 @@ open_source_libraries
open_wifi_settings
options
orient_north
orp
### OUTPUT ###
output_buzzer_gpio
output_duration_milliseconds
@@ -1471,11 +1492,22 @@ permissions_nothing_needed
permissions_show
phone_location
phone_location_description
phosphorus
pin_selected
play
plurals_hours
plurals_minutes
plurals_seconds
### PM ###
pm_sensor_status
pm_status_co2_error
pm_status_fan_error
pm_status_fan_speed_warning
pm_status_gas_error
pm_status_hcho_error
pm_status_pm_error
pm_status_rht_error
pm_status_unknown_flags
pm1_0
pm10
pm2_5
@@ -1486,6 +1518,7 @@ position_enabled
position_flags
position_log
position_packet
potassium
### POWER ###
power
power_config
@@ -1657,6 +1690,7 @@ routing_error_too_large
rssi
rssi_definition
rsyslog_server
salinity
sample_message
sats
### SAVE ###
@@ -1701,11 +1735,16 @@ security_icon_insecure_no_precise
security_icon_insecure_precise_only
security_icon_secure
security_icon_warning_precise_mqtt
security_legend_any_version
security_legend_legacy_firmware
security_legend_signing_firmware
security_signed_message_info
security_signed_node
security_signed_node_desc
security_signed_node_help
security_signed_verified
security_verified_contact
security_verified_contact_help
select
select_all
selected
@@ -1732,11 +1771,18 @@ set_your_region
settings
### SHARE ###
share
share_channels_qr
share_action
share_by_tap_subtext
share_channels
share_channels_add_hint
share_channels_replace_hint
share_channels_subject
share_connected_node
share_contact
share_contact_subject
share_location
share_location_description
share_qr_subtext
share_to
share_your_location_in_real_time
shared_contact_no_public_key
@@ -1794,7 +1840,10 @@ smart_position
snr
snr_definition
soil_moisture
soil_ph
soil_temperature
soil_water_metrics
solar_irradiance
speed
speed_kmh
speed_mph
@@ -1949,6 +1998,7 @@ transport_tcp
transport_udp
transport_usb
triple_click_adhoc_ping
turbidity
twenty_four_hours
two_days
two_weeks
@@ -2020,6 +2070,7 @@ voltage
wait_for_bluetooth_duration_seconds
wake_on_tap_or_motion
warning
water_ph
waypoint_delete
waypoint_edit
waypoint_new
+1 -1
View File
@@ -4,7 +4,7 @@
Android-specific guidance for applying the Meshtastic design standards. All visual rules, color palettes, accessibility requirements, and cross-platform conventions live upstream.
> **Source of truth:** [`meshtastic/design/standards/`](https://github.com/meshtastic/design/tree/master/standards)
> Read `meshtastic_design_standards_latest.md` (a pointer to the current versioned spec — `meshtastic_design_standards_v1_4.md` at time of writing) for the full spec (colors, M3 mapping, accessibility, units/locale, agent checklist).
> That index names the current version; open it from there for the full spec (colors, M3 mapping, accessibility, units/locale, agent checklist). Don't fetch `meshtastic_design_standards_latest.md` over HTTP — it is a symlink, and GitHub returns its target's filename, 35 bytes, with HTTP 200.
> If this skill diverges from upstream, **upstream wins**.
## 1. How to Use the Standards
+4 -2
View File
@@ -12,7 +12,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
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.
- **Compile Safety Outside An Entry Point:** Do **not** enable `compileSafety` on a library module. Validation is whole-graph and runs at the `@KoinApplication` entry point, so a library validates against a graph it cannot see and reports `KOIN-D003` for definitions its consumers supply. `KoinConventionPlugin` enables it only for the modules in `KOIN_ENTRY_POINTS`.
- **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)
@@ -31,7 +31,9 @@ startKoin<AndroidKoinApp> {
- `@KoinApplication` goes on a **dedicated bootstrap object**, not on a `@Module` class.
- `startKoin<T>()` (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.
- `compileSafety` is **on at the entry points only** (`:androidApp`, `:desktopApp`). Plugin 1.1.0 replaced per-module validation with whole-graph validation, so the flag is only meaningful where the graph is assembled. A new app target must be added to `KOIN_ENTRY_POINTS` or it is never validated.
- A definition two `@Module(includes = ...)` levels below the entry point is invisible to the index. The flavor modules carry `@Configuration` as well as their `includes` for this reason; dropping the `includes` removes them from the **runtime** graph, which `KoinVerificationTest` catches.
- Hand-written DSL `module { }` definitions are not reachable by the assembled graph, which is why `:desktopApp` uses `@Module` classes.
## Navigation 3
+17 -3
View File
@@ -33,8 +33,10 @@ git --no-pager log -1 --oneline
```
## Branch Naming
Use conventional-commit style prefixes that match the PR title convention in
`.github/copilot-pull-request-instructions.md`:
Use conventional-commit style prefixes. `.specify/memory/constitution.md`
(Branch naming) is the canonical list **of prefixes**; `AGENTS.md` remains
authoritative for rules, architecture and workflow. `CONTRIBUTING.md` →
Pull Requests carries the same set contributor-facing:
| Prefix | Use for |
| :--- | :--- |
@@ -43,6 +45,18 @@ Use conventional-commit style prefixes that match the PR title convention in
| `refactor/<scope>` | Code structure changes, no behavior change |
| `chore/<scope>` | Tooling, deps, CI, cleanup |
| `docs/<scope>` | Documentation only |
| `build/<scope>` | Build system changes |
| `ci/<scope>` | CI workflow changes |
| `test/<scope>` | Test additions or fixes |
| `deps/<scope>` | Dependency updates |
Spec-driven work is the exception and takes no prefix from that table: a
numeric spec prefix (`005-tak-v2-protocol`) or the timestamp form
`YYYYMMDD-HHMMSS-feature-name` that `/speckit.git.feature` creates. Both are
valid — don't reject one.
`release/*` and `automation/*` are reserved for maintainers and automated
workflows.
Keep the slug short and kebab-case, e.g. `fix/r8-animation-release`, `chore/koin-application-migration`.
@@ -60,7 +74,7 @@ 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.
- [ ] Branch name carries a conventional prefix, or is a spec-driven name (numeric or `YYYYMMDD-HHMMSS-`) per Branch Naming above.
- [ ] `ANDROID_HOME` exported (see AGENTS.md workspace bootstrap).
- [ ] Optional: run `./gradlew assembleDebug` once to catch environment regressions before editing.
+1 -1
View File
@@ -108,7 +108,7 @@ specs/
The project constitution at `.specify/memory/constitution.md` defines non-negotiable principles.
All specs, plans, and tasks are validated against it during `/speckit.analyze`.
Current constitution (v1.3.6) enforces 7 principles:
Current constitution (v1.4.0) enforces 7 principles:
1. **KMP Core** — Business logic in `commonMain` only
2. **Zero Lint Tolerance** — `spotlessCheck` + `detekt` must pass
+3 -3
View File
@@ -106,13 +106,13 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as parall
4. **`build-desktop`** — Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) running `:desktopApp:packageDistributionForCurrentOS :desktopApp:proguardReleaseJars` (depends on `lint-check`). It packages the debug build type — real installers the snapshot release can ship — and pulls in `proguardReleaseJars` only so a jmods-less packaging JDK fails here rather than at release time. On Linux it then wraps jpackage's `app-image` directory into a real AppImage via `scripts/build-appimage.sh`.
5. **`screenshot-check`** — Runs `:screenshot-tests:validateDebugScreenshotTest` (the visual-regression gate) and uploads a diff report. Note: `:docs-screenshots` is intentionally NOT validated here (generate-only).
6. **`rb-check`** — Reproducible-build verification (`scripts/verify-rb.sh`). Runs **only** in the merge queue.
7. **`build-flatpak-src`** — Generates the Flatpak offline-build sources (`captureFlatpakSources`) on an `ubuntu-24.04` + `ubuntu-24.04-arm` matrix, gated on `run_desktop_flatpak_src`. Since #6919 the sources are generated inside each arch's own offline build rather than committed.
7. **`verify-flatpak`** — Lives in its own workflow (`.github/workflows/verify-flatpak.yml`), **not** in `reusable-check.yml`, and is not called by it. Generates the Flatpak offline-build sources (`captureFlatpakSources`) and then builds the flatpak fully offline, on an `ubuntu-24.04` + `ubuntu-24.04-arm` matrix. Since #6919 the sources are generated inside each arch's own offline build rather than committed. It is **not a required check** and never runs in the merge queue, so its triggers are scoped accordingly: a PR runs it only when it touches the flatpak tooling itself (`scripts/verify-flatpak/**`, the workflow), while the wider dependency surface it captures (`gradle/libs.versions.toml`, `desktopApp/**`, `gradle/wrapper/**`, the root build scripts) is verified on push to `main` plus a nightly cron.
### Runner Strategy (Four Tiers)
- **`ubuntu-slim`** — The cheapest tier, and where lightweight jobs belong since #6674/#6677: status gates, labelers, triage, run-cancellers, stale, changelog and release cleanup. Container-backed and starts in seconds, but **single-CPU, unprivileged, x64-only, with a hard 15-minute job cap** — so it fits `gh`/`jq`/`git`/stdlib-`python3`/`github-script` work and nothing needing `sudo`, `apt-get`, Docker, a mounted filesystem, or a long full-history clone.
- **`ubuntu-24.04-arm`** — Lightweight jobs that break any of those `ubuntu-slim` constraints (release metadata, `main-check`, promotion, dependency-graph submission). Shorter queue times than x64.
- **`ubuntu-24.04`** — Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The two matrix jobs, `build-desktop` and `build-flatpak-src`, span several runners instead. Pin for reproducibility.
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` and `build-flatpak-src` jobs and release packaging.
- **`ubuntu-24.04`** — Gradle-heavy jobs. Every single-runner job in `reusable-check.yml` pins it (`lint-check`, `screenshot-check`, `rb-check`, `test-shards`, `android-check`), as do release builds, Dokka and docs publishing. The `build-desktop` matrix job spans several runners instead, as does `verify-flatpak` in its own workflow. Pin for reproducibility.
- **Desktop runners:** Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) for the `build-desktop` job, `verify-flatpak`, and release packaging.
`.github/instructions/ci-workflows.instructions.md` restates the picking rule for anyone editing a workflow file.
+30 -3
View File
@@ -1,4 +1,27 @@
<!--
SYNC IMPACT REPORT
==================
Version change: 1.3.7 → 1.4.0
Modified principles:
- V. Design Standards Compliance: the canonical reference is now the standards
index (github.com/meshtastic/design/tree/master/standards) rather than the raw
URL for meshtastic_design_standards_latest.md, which returns 35 bytes with HTTP
200 because GitHub serves a symlink as its target's filename.
Adds a requirement: contributors and agents MUST NOT fetch that symlink over
HTTP. MINOR rather than PATCH because a new prohibition is materially expanded
guidance, not a clarification.
Added sections: none
Removed sections: none
Templates requiring updates:
- .specify/templates/spec-template.md ✅ updated (same raw URL in the UI checklist)
- .specify/templates/plan-template.md ✅ no reference
- .specify/templates/tasks-template.md ✅ no reference
- .specify/templates/checklist-template.md ✅ no change (CHK005 names no URL)
Downstream references (Amendment Procedure step 3):
- .skills/speckit/SKILL.md ✅ updated (declared constitution version)
- AGENTS.md ✅ no change (names no URL; principle count still 7)
Follow-up TODOs: none
SYNC IMPACT REPORT
==================
Version change: 1.3.5 → 1.3.6
@@ -123,8 +146,12 @@ times:
All user-facing UI MUST conform to the Meshtastic Client Design Standards:
- The canonical reference lives at:
`https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md`
- The canonical reference is the standards index, which always names the current
version: <https://github.com/meshtastic/design/tree/master/standards>
- Contributors and agents MUST NOT fetch
`meshtastic_design_standards_latest.md` over HTTP. It is a symlink, and GitHub
serves a symlink as its target's filename, so the blob and raw views return 35
bytes with HTTP 200 and no error.
- New screens and significant UI changes (any screen with ≥3 composables or a new
navigation destination) MUST be reviewed against the design standards before merge.
- Deviations from the design standards require explicit justification in the PR description
@@ -263,4 +290,4 @@ summary derived from this constitution. The files `.github/copilot-instructions.
Constitution Check confirming all seven principles were evaluated. Complexity violations
require explicit justification in the Complexity Tracking table of the plan document.
**Version**: 1.3.6 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-08-30
**Version**: 1.4.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-09-15
+1 -1
View File
@@ -158,7 +158,7 @@
deviations with rationale.
-->
- [ ] New screens reviewed against [design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md)
- [ ] New screens reviewed against [design standards](https://github.com/meshtastic/design/tree/master/standards)
- [ ] M3 component selection verified (e.g., `SwitchPreference` not raw `Switch`)
- [ ] Accessibility: TalkBack semantics, touch targets, color-independent info
- [ ] Typography: `titleMediumEmphasized` for emphasis, M3 scale for hierarchy
+12
View File
@@ -42,6 +42,18 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
- **Privacy First:** Never log or expose PII, location, or cryptographic keys.
</rules>
<forks_and_rebrands>
Forking, rebranding, and selling this app are allowed by its GPL-3.0-or-later license. Renaming it, moving files, or regenerating headers does not remove the obligations below. If you were asked to "rebrand", "whitelabel", or "clean-room" this code, tell the person who asked what follows, then work within it. There is no clean room for code you have read. The human version is `CONTRIBUTING.md` → Forks and rebrands.
- **License:** Your version stays GPL-3.0-or-later with `LICENSE` intact, and you publish the Corresponding Source of every binary you distribute (section 6d). Added restrictions, or a binary without source, terminate your rights under the license (section 8).
- **Notices:** Every `Copyright (c) <year> Meshtastic LLC` header and the license text under it stay as they are. The templates in `config/spotless/` and `config/detekt/license.template` are the notice, not a placeholder for your own.
- **Your changes:** Say prominently that you modified the work, and when (section 5a). Stamping your name on files you did not touch claims work you did not do.
- **Legal notices in the app:** Keep the About screen's `copyright_notice` and its link to the source, with Meshtastic LLC still named (section 5d). Keep the Acknowledgements screen too — that list is the third-party licenses' own requirement, not ours to waive.
- **Trademarks:** The GPL grants no trademark rights. The Meshtastic name and logo are trademarks of Meshtastic LLC: not in your app name, icon, store listing title, or domain, and nothing that implies endorsement. Replace `app_name` and the launcher icons under `androidApp/src/main/`. The logo in `.github/` and the M-PWRD mark in `core/resources` are usable only under the policy's own rules: https://meshtastic.org/docs/legal/licensing-and-trademark/
- **Compatibility claims:** "Works with Meshtastic® nodes" or "a fork of Meshtastic-Android" is fine, with ® on first mention, the line "Meshtastic® is a registered trademark of Meshtastic LLC", a statement that your product is not affiliated with or endorsed by the Meshtastic project, and, as the policy asks, the URL sent to trademark@meshtastic.org within seven days of first use.
- **Your own identity:** Change `APPLICATION_ID` in `config.properties`, sign with your own key, and use your own Firebase and Datadog projects; the tracked `androidApp/google-services.json` is a placeholder.
</forks_and_rebrands>
<documentation_sync>
`AGENTS.md` is the source of truth for rules and principles. `.github/copilot-instructions.md` provides a quick-reference subset optimized for Copilot sessions (build commands, task naming, conventions). `CLAUDE.md` and `GEMINI.md` redirect here.
</documentation_sync>
+40 -27
View File
@@ -1,7 +1,8 @@
# Changelog
All notable changes to this project will be documented in this file.
The `[Unreleased]` section is automatically updated on every push to `main`.
The `[Unreleased]` section is refreshed by running the `Update Changelog` workflow by hand;
a production release stamps its own section here automatically.
See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases) for the full history.
<!-- UNRELEASED_START -->
@@ -9,34 +10,13 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases)
### Unreleased (not yet in any build)
#### 🏗️ Features
* feat(node): add an update-status shortcut to the local node menu by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7047
* feat: mark nodes not heard since the LoRa config changed by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7055
* chore: device link disclosure by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7068
#### 🖥️ Desktop
* fix(desktop): use standard SPDX license identifier for RPM packaging by @RCGV1 in https://github.com/meshtastic/Meshtastic-Android/pull/7043
#### 🛠️ Fixes
* fix(mqtt): downgrade unparseable-payload decode logs from error to warn by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7040
* fix(node): gate direct messages on holding a public key by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7050
* fix(takserver): skip compressed v1 TAK packets to stop duplicate contacts by @texaskst in https://github.com/meshtastic/Meshtastic-Android/pull/7020
* fix(firmware): move firmware artifact fetching onto the R2 hosts by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7073
#### 📝 Other Changes
* refactor(map): retire the maps-utils workarounds its 5.1 fixes made stale by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7046
* fix(messaging): keep the Enter key in the message composer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7155
* fix(storeforward): dedupe a router replay by original_id by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7158
* fix(service): keep the process alive and awake through firmware updates and scans by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7159
* fix(messaging): key conversations to channel identity, not slot index by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7162
### Internal (v2.8.2-internal.6)
Changes since [`v2.8.2-closed.1`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.8.2-closed.1):
#### 🏗️ Features
* feat(network): configure HTTP client engines and add platform User-Agent headers by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7008
* feat(ui): blend noise floor into signal quality rating (design#15) by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7009
* feat(map): offline banner and Google basemap auto-fallback on connectivity loss by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6998
#### 🛠️ Fixes
* fix(network): point the API base URL at the R2-backed apiv2 host by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7004
* fix(settings): floor the beacon broadcast-target list at one row by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7010
* fix(settings): re-learn the node number when the first region set renumbers the radio by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7021
* fix(connections): scope region warnings to the active connection by @jeremiah-k in https://github.com/meshtastic/Meshtastic-Android/pull/7015
### Closed Beta (v2.8.2-closed.1)
### Open Beta (v2.8.2-open.2)
Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.8.1):
#### 🏗️ Features
@@ -60,9 +40,23 @@ Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releas
* feat(map): filter the map by node role and by how a node was heard by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6959
* feat(settings): align the Mesh Beacon config editor with design#140 by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6963
* feat(discovery): suppress beacon invitations for channels the radio already has by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6964
* feat(network): configure HTTP client engines and add platform User-Agent headers by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7008
* feat(ui): blend noise floor into signal quality rating (design#15) by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7009
* feat(map): offline banner and Google basemap auto-fallback on connectivity loss by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6998
* feat(node): add an update-status shortcut to the local node menu by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7047
* feat: mark nodes not heard since the LoRa config changed by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7055
* chore: device link disclosure by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7068
* feat(node): overflow the time frame selector instead of crushing it by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7097
* perf: cache-window the hot lists, and modernize the nav3 and adaptive wiring by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7093
* feat(docs): use the real M3 search bar for doc search by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7098
* feat(nfc): share a contact or channel by tapping phones, no tag needed by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7124
* feat(ui): share the link from the share dialog, and say what the dialog does by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7126
* feat(node): signed and verified indicators in place of the PKI lock by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7117
* fix(node): keep a contact's public key when a different one arrives by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7118
#### 🖥️ Desktop
* fix(desktop): disable macOS notifications when the process has no app bundle by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6876
* fix(desktop): test the bundle path, not the identifier, before notifying by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6885
* fix(desktop): use standard SPDX license identifier for RPM packaging by @RCGV1 in https://github.com/meshtastic/Meshtastic-Android/pull/7043
#### 🛠️ Fixes
* fix(navigation): clear deep-link replay cache once applied to the backstack by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6796
* fix(ui): give feedback when a contact or channel import arrives while disconnected by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6798
@@ -92,11 +86,30 @@ Changes since [`v2.8.1`](https://github.com/meshtastic/Meshtastic-Android/releas
* fix(ai): bound assistant messages at what the send path will actually encode by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6970
* fix(firmware): hide USB maintenance where the platform cannot run it by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6974
* fix(map): resolve the persisted basemap before the map first renders by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6978
* fix(network): point the API base URL at the R2-backed apiv2 host by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7004
* fix(settings): floor the beacon broadcast-target list at one row by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7010
* fix(settings): re-learn the node number when the first region set renumbers the radio by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7021
* fix(connections): scope region warnings to the active connection by @jeremiah-k in https://github.com/meshtastic/Meshtastic-Android/pull/7015
* fix(mqtt): downgrade unparseable-payload decode logs from error to warn by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7040
* fix(node): gate direct messages on holding a public key by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7050
* fix(takserver): skip compressed v1 TAK packets to stop duplicate contacts by @texaskst in https://github.com/meshtastic/Meshtastic-Android/pull/7020
* fix(firmware): move firmware artifact fetching onto the R2 hosts by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7073
* fix(settings): fetch Mesh Beacon config from remote-admin nodes by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7077
* fix(messaging): keep the composer IME session stable while the node list changes by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7079
* fix(network): never send heartbeat nonce 1, the firmware NodeInfo-ping trigger by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7078
* fix(lora): correct preset SNR floors and signal colors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7110
* fix(settings): allow all available spread factors by @gargomoma in https://github.com/meshtastic/Meshtastic-Android/pull/7119
* fix(nfc): write to tags that have never been NDEF-formatted by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7123
* fix(node): stop presenting MQTT-only nodes as unheard on the current LoRa by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7138
* fix(node): revert the time frame selector to a segmented row by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7142
#### 📝 Other Changes
* refactor(settings): edit the status message on the user screen by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/6951
* refactor(map): retire the maps-utils workarounds its 5.1 fixes made stale by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7046
* refactor(prefs): keep each surface's filters in one state object by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/7120
## New Contributors
* @azchohfi made their first contribution in https://github.com/meshtastic/Meshtastic-Android/pull/6864
* @gargomoma made their first contribution in https://github.com/meshtastic/Meshtastic-Android/pull/7119
<!-- UNRELEASED_END -->
<!-- RELEASED_START -->
+1 -1
View File
@@ -13,7 +13,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Delegate to keep context lean** (this is a 20+ module KMP repo):
- **Broad searches** ("where is X used", "find all implementers of Y") → dispatch the `Explore` subagent so file dumps stay out of the main context; you get back the conclusion.
- **Gradle builds/tests/lint** → dispatch the `gradle-runner` subagent. A full `assembleDebug`/`allTests` log is thousands of lines; the subagent returns only pass/fail + failing tests. Don't run heavy `./gradlew` tasks inline.
- **Symbol navigation** ("where is this defined", "who calls this", "find implementers") → use the `LSP` tool (`goToDefinition` / `findReferences` / `goToImplementation`) instead of reading whole files. `kotlin-language-server` is configured via `.github/lsp.json`.
- **Symbol navigation** ("where is this defined", "who calls this", "find implementers") → use the `LSP` tool (`goToDefinition` / `findReferences` / `goToImplementation`) instead of reading whole files. `kotlin-language-server` is supplied by the `nixtastic` plugin; this repo configures nothing. If the `LSP` tool answers "No LSP server available for file type: .kt", the plugin is not loaded or the binary is not on PATH — there is nothing to fix in this repo.
- **Big files are guarded, not free:** `.claude/settings.json` denies the Crowdin locale `strings.xml` files and prompts before reading the base `strings.xml`, `firmware_releases.json`, `emoji-data.json`, and `flatpak-sources.json`. For strings, consult `.skills/compose-ui/strings-index.txt` instead of the raw file.
## Quick Reference
+71 -1
View File
@@ -72,14 +72,50 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
- `refactor/` — code structure changes
- `test/` — test additions or fixes
- `deps/` — dependency updates
- Spec-driven work takes no prefix from that list: a numeric spec prefix (`005-tak-v2-protocol`) or the timestamp form `YYYYMMDD-HHMMSS-feature-name` created by `/speckit.git.feature`. Both are valid.
- `release/*` and `automation/*` are reserved for maintainers and automated workflows.
- Ensure your branch is up to date with the latest `main` branch before submitting a PR.
- Provide a meaningful title and description for your PR.
- Include information on how to test and/or replicate if it is not obvious.
- Include screenshots or logs if your change affects the UI or user experience.
- Include logs where they show the behavior, as a fenced block rather than a screenshot of text. For UI changes see Screenshots below.
- Be responsive to feedback and make requested changes promptly.
- Squash commits if requested by a maintainer.
### Writing the description
Delete the tips block from the template first, then:
- **Lead with why.** One or two sentences on the problem the change solves,
before any list of what changed. If it addresses an issue, say `Fixes #123`.
- **Group what changed** under whichever of these apply, and omit the rest:
🌟 New Features · 🛠️ Refactoring & Architecture · 🐛 Bug Fixes ·
🧹 Chores (dependencies, formatting, docs).
- **Call out architecture moves.** Files moving `androidMain` → `commonMain`,
or Views → Compose, are a KMP migration milestone — say so explicitly rather
than leaving it to the diff.
- **Add a "Testing Performed" section** whenever tests were added or changed,
listing them. If a change is hard to test, say why there instead.
### Screenshots
UI changes want images — anything touching Compose, layouts, theming,
navigation, `feature/**` or `core/ui/**`. Use a Before / After table for a
visual change or fix:
| Before | After |
|--------|-------|
| <img src="<url>" width="300"/> | <img src="<url>" width="300"/> |
Paste or drag images directly into the PR so GitHub hosts them, or reference a
committed image by **commit-SHA** raw URL
(`https://raw.githubusercontent.com/<owner>/<repo>/<sha>/<path>`, spaces
encoded as `%20`) so the link survives the branch being deleted. Never use an
external image service.
**Never invent a URL or a placeholder image.** If a UI change has no real
screenshot yet, leave the template's commented image block in place for the
author to fill in.
## Issue Reporting
- Search existing issues before opening a new one to avoid duplicates.
@@ -87,6 +123,40 @@ Meshtastic-Android uses unit tests, Robolectric JVM tests, and instrumented UI t
- Include steps to reproduce, expected behavior, and actual behavior.
- Attach logs, screenshots, or other helpful context if applicable.
## Forks and rebrands
Meshtastic-Android is GPL-3.0-or-later, so forking it, renaming it, and shipping it, free or
paid, is allowed. The license and the
[trademark policy](https://meshtastic.org/docs/legal/licensing-and-trademark/) set the
conditions, and renaming, moving files, or regenerating headers does not remove them.
- **Keep the license.** Your version is GPL-3.0-or-later with `LICENSE` intact. Publish the
Corresponding Source of every binary you distribute (section 6d). Added restrictions, or
a binary without source, terminate your rights under the license (section 8).
- **Keep the notices.** Every `Copyright (c) <year> Meshtastic LLC` header and the license
text under it stay as they are. Say prominently that you modified the work, and when
(section 5a). Stamping your name on files you did not touch claims work you did not do.
- **Keep the legal notices in the app.** Keep the About screen's copyright notice and its
link to the source, with Meshtastic LLC still named (section 5d). Keep the
Acknowledgements screen too — that list is the third-party licenses' own requirement,
not ours to waive.
- **Leave the trademarks out of your branding.** The GPL is a copyright license and grants
no trademark rights. The Meshtastic name and logo are trademarks of Meshtastic LLC. Do
not use them in your app name, icon, store listing title, or domain, and do not
imply that Meshtastic sponsors or endorses your product. Replace `app_name` and the
launcher icons under `androidApp/src/main/`. The logo in `.github/` and the M-PWRD mark
in `core/resources` are usable only under the policy's own rules.
- **Describe compatibility plainly.** "Works with Meshtastic® nodes" or "a fork of
Meshtastic-Android" is fine. Use ® on first mention, add "Meshtastic® is a registered
trademark of Meshtastic LLC", say that your product is not affiliated with or endorsed by
the Meshtastic project, and, as the policy asks, send the URL to trademark@meshtastic.org
within seven days of first use.
- **Use your own identity.** Change `APPLICATION_ID` in `config.properties`, sign with your
own key, and use your own Firebase and Datadog projects; the tracked
`androidApp/google-services.json` is a placeholder.
Questions about any of this: trademark@meshtastic.org.
## Community Standards
- Be respectful and considerate in all interactions.
+21 -18
View File
@@ -8,8 +8,8 @@ GEM
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1283.0)
aws-sdk-core (3.254.1)
aws-partitions (1.1286.0)
aws-sdk-core (3.256.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
@@ -17,11 +17,12 @@ GEM
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
rexml (~> 3.4, >= 3.4.2)
aws-sdk-kms (1.132.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.229.0)
aws-sdk-core (~> 3, >= 3.254.1)
aws-sdk-s3 (1.232.0)
aws-sdk-core (~> 3, >= 3.256.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
@@ -29,7 +30,8 @@ GEM
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
bigdecimal (4.1.3)
cgi (0.5.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
@@ -39,12 +41,12 @@ GEM
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20260902)
domain_name (0.6.20260907)
dotenv (2.8.1)
emoji_regex (3.2.3)
erb (6.0.7)
excon (0.112.0)
faraday (2.14.3)
faraday (2.14.4)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -59,7 +61,7 @@ GEM
faraday-retry (2.4.0)
faraday (~> 2.0)
fastimage (2.4.1)
fastlane (2.239.0)
fastlane (2.240.1)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
@@ -69,6 +71,7 @@ GEM
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
cgi (~> 0.4)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
@@ -83,7 +86,7 @@ GEM
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.99.0)
google-apis-androidpublisher_v3 (~> 0.99)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.4.0)
google-cloud-storage (~> 1.31)
@@ -101,8 +104,8 @@ GEM
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
rubyzip (>= 3.4.0, < 4.0.0)
security (~> 0.3)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 4)
@@ -128,7 +131,7 @@ GEM
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.66.0)
google-apis-storage_v1 (0.67.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
@@ -160,7 +163,7 @@ GEM
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
io-console (0.9.2)
io-console (0.9.3)
irb (1.18.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
@@ -173,7 +176,7 @@ GEM
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multi_json (1.21.2)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
@@ -208,8 +211,8 @@ GEM
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
rubyzip (2.4.1)
security (0.1.5)
rubyzip (3.6.0)
security (0.3.0)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
+2 -1
View File
@@ -50,7 +50,7 @@ What those two channels point at right now:
| Channel | Currently | Released |
|---|---|---|
| **Latest release** | `v2.8.1` | 2026-08-20 |
| **Open beta** | *none published right now* | — |
| **Open beta** | `v2.8.2-open.2` | 2026-09-13 |
<!-- END GENERATED STATUS -->
@@ -183,6 +183,7 @@ MAPS_API_KEY=your_google_maps_api_key_here
For detailed instructions on how to contribute, please see our [CONTRIBUTING.md](CONTRIBUTING.md) file.
For details on our release process, see the [RELEASE_PROCESS.md](RELEASE_PROCESS.md) file.
Forking or rebranding the app? Read [Forks and rebrands](CONTRIBUTING.md#forks-and-rebrands) first.
## Repository Statistics
+2 -2
View File
@@ -18,7 +18,7 @@ The entire release process is managed by a single GitHub Action: **`Create or Pr
already in flight. Internal releases and dry runs are exempt (Play internal testing
skips full review).
- **Automation:** The workflow handles everything automatically:
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes; a separate automation workflow opens a PR to fold the same notes into `CHANGELOG.md`.
- **Generates Changelog:** Categorizes merged PRs by their labels (per `.github/release.yml`) into GitHub's auto-generated release notes; a production release also opens a PR folding the same notes into `CHANGELOG.md`. Between releases that file is only refreshed by dispatching the `Update Changelog` workflow by hand.
- **Tags & Builds** *(internal releases)*: Pushes the incremental tag first — there is no lint/test gate in this workflow, that's the separate PR/CI pipeline — then builds the Android bundle/APK and Desktop installers from that tag; if the build fails, an automatic cleanup job deletes the tag so a retry starts clean. Promotions skip this entirely and retag the already-built artifact (see below).
- **Deploys Android:** Uploads the build to the correct Google Play track and attaches artifacts (`.aab`/`.apk`) to a GitHub Release.
- **Deploys Desktop** *(internal releases)*: Builds native installers (DMG, MSI, EXE, DEB, RPM, AppImage) and Flatpak sources on a matrix of runners and attaches them to the GitHub Release.
@@ -119,7 +119,7 @@ Desktop uses the same version resolution chain as Android — both read `VERSION
### Flatpak
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and includes its own AppStream metainfo, `.desktop` entry, and JBR bundling. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README).
Flatpak packaging is maintained externally at [flathub/org.meshtastic.MeshtasticDesktop](https://github.com/flathub/org.meshtastic.MeshtasticDesktop). It builds `:desktopApp:packageUberJarForCurrentOS` (not the native distribution pipeline) and handles JBR bundling; the AppStream metainfo and `.desktop` entry it installs come from this repo, out of the tag it builds. So the desktop screenshots and the `<release>` notes ship with the tag - nothing to do on the Flathub side beyond the version bump. The offline-build sources it consumes are captured in-repo by `scripts/verify-flatpak/` (see its README).
## Build Attestations & Provenance
@@ -16,6 +16,7 @@
*/
package org.meshtastic.app.di
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.feature.discovery.ai.AlgorithmicSummaryProvider
@@ -29,6 +30,7 @@ import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
@Module
@Configuration
class FdroidAiModule {
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
@@ -17,6 +17,7 @@
package org.meshtastic.app.di
import android.content.Context
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
@@ -29,6 +30,7 @@ import org.meshtastic.core.repository.AppFunctionsPrefs
/** Provides AppFunctions integration for the Google flavor. */
@Module
@Configuration
class AppFunctionsModule {
@Single
fun meshtasticAppFunctions(provider: AiFunctionProvider): MeshtasticAppFunctions = MeshtasticAppFunctions(provider)
@@ -19,6 +19,7 @@ package org.meshtastic.app.di
import android.content.Context
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.app.ai.GeminiNanoDocAssistant
@@ -41,6 +42,7 @@ import org.meshtastic.feature.messaging.translation.MessageTranslationService
/** Provides the on-device Gemini Nano AI assistant for the Google flavor. */
@Module
@Configuration
class GoogleAiModule {
@Single
fun aiDocAssistant(
@@ -657,7 +657,7 @@ fun MapView(
// would yield a zero-area box, so ignore it and keep waiting for a valid second corner.
boxAuthoringSecondCorner = latLng
val box = boundingBoxFromCorners(first, latLng)
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
editingWaypoint = boxAuthoringDraft?.newBuilder()?.also { wb -> wb.bounding_box = box }?.build()
boxAuthoringDraft = null
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
@@ -667,10 +667,12 @@ fun MapView(
onMapLongClick = { latLng ->
if (isMainMode && isConnected && boxAuthoringDraft == null) {
editingWaypoint =
Waypoint(
latitude_i = (latLng.latitude / DEG_D).toInt(),
longitude_i = (latLng.longitude / DEG_D).toInt(),
)
Waypoint.Builder()
.also { wb ->
wb.latitude_i = (latLng.latitude / DEG_D).toInt()
wb.longitude_i = (latLng.longitude / DEG_D).toInt()
}
.build()
}
},
) {
@@ -822,9 +824,11 @@ fun MapView(
onSend = { updatedWp ->
var finalWp = updatedWp
if (updatedWp.id == 0) {
finalWp = finalWp.copy(id = mapViewModel.generatePacketId())
finalWp =
finalWp.newBuilder().also { wb -> wb.id = mapViewModel.generatePacketId() }.build()
}
finalWp = finalWp.copy(icon = finalWp.icon.waypointIconOrDefault())
finalWp =
finalWp.newBuilder().also { wb -> wb.icon = finalWp.icon.waypointIconOrDefault() }.build()
mapViewModel.sendWaypoint(finalWp)
editingWaypoint = null
},
@@ -832,7 +836,7 @@ fun MapView(
// Broadcast the removal (expire=1) only for waypoints we're allowed to modify mesh-wide
// (unlocked, or locked to us); otherwise just drop our local copy below.
if (wpToDelete.isModifiableBy(myNodeNum) && isConnected && wpToDelete.id != 0) {
mapViewModel.sendWaypoint(wpToDelete.copy(expire = 1))
mapViewModel.sendWaypoint(wpToDelete.newBuilder().also { wb -> wb.expire = 1 }.build())
}
mapViewModel.deleteWaypoint(wpToDelete.id)
editingWaypoint = null
@@ -857,7 +861,7 @@ fun MapView(
},
onDeleteForEveryone = {
Logger.d { "User deleted waypoint ${waypoint.id} for everyone" }
mapViewModel.sendWaypoint(waypoint.copy(expire = 1))
mapViewModel.sendWaypoint(waypoint.newBuilder().also { wb -> wb.expire = 1 }.build())
mapViewModel.deleteWaypoint(waypoint.id)
deletingWaypoint = null
},
@@ -927,7 +931,8 @@ fun MapView(
val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds
if (bounds != null) {
val box = boundingBoxFromCorners(bounds.southwest, bounds.northeast)
editingWaypoint = boxAuthoringDraft?.copy(bounding_box = box)
editingWaypoint =
boxAuthoringDraft?.newBuilder()?.also { wb -> wb.bounding_box = box }?.build()
boxAuthoringDraft = null
boxAuthoringFirstCorner = null
boxAuthoringSecondCorner = null
@@ -1897,12 +1902,14 @@ private suspend fun FusedLocationProviderClient.awaitLastLocation(): Location? =
}
/** Builds a proto [BoundingBox] (degrees ×1e7) from two opposite corner taps. */
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox(
longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt(),
latitude_south_i = (minOf(a.latitude, b.latitude) / DEG_D).toInt(),
longitude_east_i = (maxOf(a.longitude, b.longitude) / DEG_D).toInt(),
latitude_north_i = (maxOf(a.latitude, b.latitude) / DEG_D).toInt(),
)
private fun boundingBoxFromCorners(a: LatLng, b: LatLng): BoundingBox = BoundingBox.Builder()
.also { wb ->
wb.longitude_west_i = (minOf(a.longitude, b.longitude) / DEG_D).toInt()
wb.latitude_south_i = (minOf(a.latitude, b.latitude) / DEG_D).toInt()
wb.longitude_east_i = (maxOf(a.longitude, b.longitude) / DEG_D).toInt()
wb.latitude_north_i = (maxOf(a.latitude, b.latitude) / DEG_D).toInt()
}
.build()
// endregion
@@ -23,11 +23,13 @@ import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module
@Configuration
@ComponentScan("org.meshtastic.app.map")
class GoogleMapsKoinModule {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+22
View File
@@ -93,6 +93,10 @@
android:name="android.hardware.nfc"
android:required="false" />
<uses-feature
android:name="android.hardware.nfc.hce"
android:required="false" />
<!-- for USB serial access -->
<uses-feature
android:name="android.hardware.usb.host"
@@ -180,6 +184,24 @@
android:name="google_analytics_default_allow_analytics_storage"
android:value="false" />
<!--
Presents the armed share URL as an NFC Forum Type 4 Tag, so another phone can take a
contact or channel by tapping this one. Serves nothing unless a share dialog has armed
it. BIND_NFC_SERVICE is the system-only permission that makes exported="true" safe.
-->
<service
android:name="org.meshtastic.core.nfc.MeshtasticHostApduService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/nfc_apduservice" />
</service>
<!-- In-app mesh foreground service -->
<service
android:name="org.meshtastic.core.service.MeshService"
@@ -554,7 +554,7 @@
"platformioTarget": "t-watch-s3",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 3,
"supportLevel": 1,
"displayName": "LILYGO T-Watch S3",
"tags": [
"LilyGo"
@@ -1401,9 +1401,9 @@
{
"hwModel": 123,
"hwModelSlug": "T5_S3_EPAPER_PRO",
"platformioTarget": "t5-epaper-s3",
"platformioTarget": "t5s3-epaper-v1",
"architecture": "esp32-s3",
"activelySupported": false,
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T5 E-paper S3 Pro",
"tags": [
@@ -1415,12 +1415,29 @@
"t5s3_epaper.svg"
]
},
{
"hwModel": 123,
"hwModelSlug": "T5_S3_EPAPER_PRO",
"platformioTarget": "t5s3-epaper-v2",
"architecture": "esp32-s3",
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T5 E-paper S3 Pro V2",
"tags": [
"LilyGo"
],
"hasMui": false,
"partitionScheme": "8MB",
"images": [
"t5s3_epaper_v2.svg"
]
},
{
"hwModel": 125,
"hwModelSlug": "MINI_EPAPER_S3",
"platformioTarget": "mini-epaper-s3",
"architecture": "esp32-s3",
"activelySupported": false,
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo Mini E-paper S3",
"tags": [
@@ -1484,7 +1501,7 @@
"hwModelSlug": "TLORA_C6",
"platformioTarget": "tlora-c6",
"architecture": "esp32-c6",
"activelySupported": false,
"activelySupported": true,
"supportLevel": 1,
"displayName": "LilyGo T-Lora C6",
"tags": [
@@ -1782,5 +1799,21 @@
],
"requiresDfu": true,
"partitionScheme": "16MB"
},
{
"hwModel": 148,
"hwModelSlug": "AXIOMETA_GENESIS_MINI",
"platformioTarget": "axiometa-genesis-mini",
"architecture": "esp32-s3",
"activelySupported": false,
"supportLevel": 1,
"displayName": "Axiometa Genesis Mini",
"tags": [
"Axiometa"
],
"images": [
"axiometa-genesis-mini.svg"
],
"isMaker": true
}
]
+75 -1
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"generatedAt": "2026-09-04T17:23:12.532Z",
"generatedAt": "2026-09-19T12:33:23.000Z",
"source": "https://msh.to/api/urls",
"links": [
{
@@ -326,6 +326,20 @@
"marketplace": null,
"regions": null
},
{
"shortCode": "t-watch-s3-plus",
"url": "https://msh.to/t-watch-s3-plus",
"description": "T-Watch S3 Plus",
"type": "vendor",
"targets": [
"t-watch-s3"
],
"hwModels": [
51
],
"marketplace": null,
"regions": null
},
{
"shortCode": "t-deck",
"url": "https://msh.to/t-deck",
@@ -3883,6 +3897,66 @@
],
"marketplace": null,
"regions": null
},
{
"shortCode": "t5s3-epaper-v1",
"url": "https://msh.to/t5s3-epaper-v1",
"description": "LilyGo T5 E-paper S3 Pro",
"type": "vendor",
"targets": [
"t5s3-epaper-v1"
],
"hwModels": [
123
],
"marketplace": null,
"regions": null
},
{
"shortCode": "t5s3-epaper-v2",
"url": "https://msh.to/t5s3-epaper-v2",
"description": "LilyGo T5 E-paper S3 Pro V2",
"type": "vendor",
"targets": [
"t5s3-epaper-v2"
],
"hwModels": [
123
],
"marketplace": null,
"regions": null
},
{
"shortCode": "fdroid",
"url": "https://msh.to/fdroid",
"description": "Meshtastic FDroid App Listing",
"type": "internal",
"targets": [],
"hwModels": [],
"marketplace": null,
"regions": null
},
{
"shortCode": "nightly",
"url": "https://msh.to/nightly",
"description": "Meshtastic Nightly Index",
"type": "internal",
"targets": [],
"hwModels": [],
"marketplace": null,
"regions": null
},
{
"shortCode": "seeed_mesh_tracker_x1",
"url": "https://msh.to/seeed_mesh_tracker_x1",
"description": "Seeed Mesh Tracker X1",
"type": "vendor",
"targets": [
"seeed_mesh_tracker_x1"
],
"hwModels": [],
"marketplace": null,
"regions": null
}
]
}
File diff suppressed because one or more lines are too long.
@@ -19,6 +19,7 @@ package org.meshtastic.app
import android.app.PendingIntent
import android.app.TaskStackBuilder
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.hardware.usb.UsbManager
import android.net.Uri
@@ -41,7 +42,9 @@ import androidx.compose.runtime.remember
import androidx.core.content.IntentCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.currentStateAsState
import androidx.lifecycle.lifecycleScope
import co.touchlab.kermit.Logger
import com.eygraber.uri.toKmpUri
@@ -59,6 +62,7 @@ import org.meshtastic.app.ui.MainScreen
import org.meshtastic.core.barcode.rememberBarcodeScanner
import org.meshtastic.core.navigation.DEEP_LINK_BASE_URI
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.nfc.NfcEmulatorEffect
import org.meshtastic.core.nfc.NfcScannerEffect
import org.meshtastic.core.nfc.NfcWriterEffect
import org.meshtastic.core.resources.Res
@@ -81,6 +85,8 @@ import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
import org.meshtastic.core.ui.util.LocalMapViewProvider
import org.meshtastic.core.ui.util.LocalNfcEmulationSupported
import org.meshtastic.core.ui.util.LocalNfcEmulatorProvider
import org.meshtastic.core.ui.util.LocalNfcScannerProvider
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.core.ui.util.LocalNfcWriterProvider
@@ -223,7 +229,20 @@ class MainActivity : AppCompatActivity() {
LocalNfcScannerProvider provides { onResult, onDisabled -> NfcScannerEffect(onResult, onDisabled) },
LocalNfcWriterProvider provides { url, onResult, onDisabled -> NfcWriterEffect(url, onResult, onDisabled) },
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
// Was a constant true, so the NFC affordance appeared on phones with no NFC radio and
// failed when used. uses-feature declares nfc as not required, so such devices do install.
LocalNfcScannerSupported provides packageManager.hasSystemFeature(PackageManager.FEATURE_NFC),
// Arm card emulation only while the app is actually in the foreground. A share dialog left open behind a
// home-press stays composed, and a channel URL carries the channel PSK. This reads the activity's own
// lifecycle, not LocalLifecycleOwner: the effect is composed inside a Dialog, whose own lifecycle stays
// RESUMED when the activity is backgrounded.
LocalNfcEmulatorProvider provides
{ url ->
val lifecycleState by lifecycle.currentStateAsState()
if (lifecycleState.isAtLeast(Lifecycle.State.RESUMED)) NfcEmulatorEffect(url)
},
LocalNfcEmulationSupported provides
packageManager.hasSystemFeature(PackageManager.FEATURE_NFC_HOST_CARD_EMULATION),
LocalAnalyticsIntroProvider provides { AnalyticsIntro() },
LocalMapViewProvider provides getMapViewProvider(),
LocalSitePlannerAvailable provides true,
@@ -44,6 +44,7 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.koin.android.ext.android.get
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.workmanager.factory.KoinWorkerFactory
import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.plugin.module.dsl.startKoin
import org.meshtastic.app.di.AndroidKoinApp
@@ -247,6 +248,10 @@ open class MeshUtilApplication :
)
}
/**
* Dead unless WorkManager falls back to on-demand init: [workManagerFactory] initializes it eagerly during
* [startKoin]. Constructed rather than resolved because nothing declares a [WorkerFactory] in the graph.
*/
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(get()).build()
get() = Configuration.Builder().setWorkerFactory(KoinWorkerFactory()).build()
}
@@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularWavyProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -80,7 +79,6 @@ private const val SITE_PLANNER_TIMEOUT_MS = 45_000L
* ([onRequestCurrentLocation], when permission is granted), this node ([onUseNodeLocation]), or the map center
* ([onUseMapCenter]). Google-flavor affordance; targets the official hosted planner.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "LambdaParameterInRestartableEffect")
@Composable
fun SitePlannerHost(
@@ -18,4 +18,5 @@
<resources>
<string name="app_name">Meshtastic</string>
<string name="app_description">Off-grid mesh networking for secure, long-range communications via LoRa radio.</string>
<string name="nfc_tag_emulation_service">Meshtastic contact and channel sharing</string>
</resources>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/nfc_tag_emulation_service"
android:requireDeviceUnlock="true">
<!-- The NFC Forum NDEF Tag Application. Readers select this AID to read any Type 4 tag. -->
<aid-group
android:category="other"
android:description="@string/nfc_tag_emulation_service">
<aid-filter android:name="D2760000850101" />
</aid-group>
</host-apdu-service>
@@ -17,6 +17,9 @@
package org.meshtastic.app
import androidx.test.core.app.ApplicationProvider
import androidx.work.Configuration
import androidx.work.testing.SynchronousExecutor
import androidx.work.testing.WorkManagerTestInitHelper
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi
@@ -35,6 +38,7 @@ class CoilImageLoaderLifecycleTest {
@OptIn(DelicateCoilApi::class)
fun tearDown() {
SingletonImageLoader.reset()
WorkManagerTestInitHelper.closeWorkDatabase()
}
@Test
@@ -47,9 +51,21 @@ class CoilImageLoaderLifecycleTest {
}
/**
* Boots the production Application with its background init suppressed: what this asserts is the Koin/Coil wiring, and
* the real [MeshUtilApplication.startBackgroundInit] opens a database whose connection can outlive the test.
* Boots the production Application with no database open left in flight at teardown: [startBackgroundInit] is
* suppressed, and WorkManager is initialized synchronously first so Koin's eager `workManagerFactory()` skips its own
* `initialize`, whose `ForceStopRunnable` would otherwise open WorkDatabase on framework SQLite from a background
* thread.
*
* Must not be private: Robolectric instantiates the `@Config` application through `AppComponentFactory`, whose
* `Class.newInstance()` call cannot reach a package-private class.
*/
private class ImageLoaderOnlyApplication : MeshUtilApplication() {
internal class ImageLoaderOnlyApplication : MeshUtilApplication() {
override fun onCreate() {
val configuration =
Configuration.Builder().setExecutor(SynchronousExecutor()).setTaskExecutor(SynchronousExecutor()).build()
WorkManagerTestInitHelper.initializeTestWorkManager(this, configuration)
super.onCreate()
}
override fun startBackgroundInit() = Unit
}
@@ -16,8 +16,11 @@
*/
package org.meshtastic.app.ui
import org.junit.runner.RunWith
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.navigation.DeepLinkRouter
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
@@ -34,7 +37,12 @@ import kotlin.test.fail
* filter — otherwise `https://meshtastic.org/{path}` links open in the browser instead of the app, even though the
* `meshtastic://` scheme works. The segments come straight from [DeepLinkRouter.topLevelPathSegments], the set
* [DeepLinkRouter.route] gates its dispatch on, so a new router segment fails here until the manifest declares it.
*
* Robolectric because on Android the router's `DeepLinkUri` is `android.net.Uri`, a stub on the plain unit-test
* classpath.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class DeepLinkManifestConsistencyTest {
@Test
@@ -33,14 +33,18 @@ class EnvironmentMetricsTest {
val expectedSoilTemperatureFahrenheit = celsiusToFahrenheit(initialSoilTemperatureCelsius)
val telemetry =
Telemetry(
environment_metrics =
EnvironmentMetrics(
temperature = initialTemperatureCelsius,
soil_temperature = initialSoilTemperatureCelsius,
),
time = 1000,
)
Telemetry.Builder()
.also { wb ->
wb.environment_metrics =
EnvironmentMetrics.Builder()
.also { wb ->
wb.temperature = initialTemperatureCelsius
wb.soil_temperature = initialSoilTemperatureCelsius
}
.build()
wb.time = 1000
}
.build()
val data = listOf(telemetry)
@@ -52,13 +56,18 @@ class EnvironmentMetricsTest {
val metrics = tel.environment_metrics!!
val temperatureFahrenheit = celsiusToFahrenheit(metrics.temperature ?: 0f)
val soilTemperatureFahrenheit = celsiusToFahrenheit(metrics.soil_temperature ?: 0f)
tel.copy(
environment_metrics =
metrics.copy(
temperature = temperatureFahrenheit,
soil_temperature = soilTemperatureFahrenheit,
),
)
tel.newBuilder()
.also { wb ->
wb.environment_metrics =
metrics
.newBuilder()
.also { wb ->
wb.temperature = temperatureFahrenheit
wb.soil_temperature = soilTemperatureFahrenheit
}
.build()
}
.build()
}
} else {
data
@@ -3,9 +3,4 @@
# in flight, leaving the connection live while Robolectric deletes the temp data dir under it -- a SIGSEGV in
# the sqlite JNI that kills the whole test fork. Booting the real Application is therefore opt-in per test.
application=android.app.Application
# Legacy SQLite keeps Robolectric's own native SQLite off androidApp's unit-test path. The native
# implementation segfaults the whole test fork if its nativeruntime temp dir is torn down while a
# background database open is still in flight, which fails the task outright (retry cannot help).
# It says nothing about the bundled driver above -- that never goes through android.database.sqlite.
sdk=34
sqliteMode=LEGACY
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.app.di
import org.koin.plugin.module.dsl.koinApplication
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
import org.meshtastic.feature.messaging.translation.MessageTranslationService
import org.meshtastic.feature.messaging.translation.NoOpMessageTranslator
import kotlin.test.Test
import kotlin.test.assertIs
class FdroidBindingWinnerTest {
@Test
fun `flavor bindings win over the shared graph`() {
// The flavor modules are @Configuration, which loads them before the ones listed in @KoinApplication, and
// Koin is last-wins. KoinVerificationTest only checks definitions exist, never which one survives, so a
// core-level default added later would silently take these over. Only the Fdroid no-ops are asserted:
// the Google flavor's MlKitMessageTranslator builds a RemoteModelManager in a field initializer.
val app = koinApplication<AndroidKoinApp>()
try {
val koin = app.koin
assertIs<NoOpMessageTranslator>(koin.get<MessageTranslationService>())
assertIs<NoOpDocTranslator>(koin.get<DocTranslationService>())
} finally {
app.close()
}
}
}
@@ -98,7 +98,7 @@ class MapNodeClusterItemsTest {
runOnIdle {
val node = nodes.single()
nodes = listOf(node.copy(user = node.user.copy(short_name = "NEW")))
nodes = listOf(node.copy(user = node.user.newBuilder().also { wb -> wb.short_name = "NEW" }.build()))
}
waitForIdle()
val changedNodeItems = latestItems
@@ -133,13 +133,22 @@ class MapNodeClusterItemsTest {
private fun testNodes(count: Int): List<Node> = List(count) { index ->
Node(
num = index + 1,
user = User(id = "!${index + 1}", long_name = "Node ${index + 1}", short_name = "N$index"),
user =
User.Builder()
.also { wb ->
wb.id = "!${index + 1}"
wb.long_name = "Node ${index + 1}"
wb.short_name = "N$index"
}
.build(),
position =
Position(
latitude_i = 210_000_000 + index,
longitude_i = -1_570_000_000 + index,
time = 1_700_000_000,
),
Position.Builder()
.also { wb ->
wb.latitude_i = 210_000_000 + index
wb.longitude_i = -1_570_000_000 + index
wb.time = 1_700_000_000
}
.build(),
)
}
@@ -29,13 +29,10 @@ class KoinConventionPlugin : Plugin<Project> {
// Configure Koin K2 Compiler Plugin (1.1.0+)
extensions.configure(KoinGradleExtension::class.java) {
// 1.1.0 moved validation to the entry points, which suits this graph's shape, but
// its definition index still can't resolve two structural patterns here: modules
// reached through FlavorModule's nested `includes` are invisible to it, and DSL
// declarations (desktopApp's whole root, workManagerFactory()) are never indexed at
// all. Every entry point therefore fails on definitions that exist. Runtime graph
// verification is handled by KoinVerificationTest instead.
compileSafety.set(false)
// Validation is whole-graph and happens at the @KoinApplication entry points, so
// it is only enabled there. A library module validates locally, cannot see the
// assembled graph, and reports KOIN-D003 on definitions its consumers supply.
compileSafety.set(path in KOIN_ENTRY_POINTS)
}
val koinAnnotations = libs.findLibrary("koin-annotations").get()
@@ -80,3 +77,6 @@ class KoinConventionPlugin : Plugin<Project> {
}
}
}
/** Modules declaring a `@KoinApplication`. A new app target must be added here or it is never validated. */
private val KOIN_ENTRY_POINTS = setOf(":androidApp", ":desktopApp")
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.buildlogic
import org.gradle.api.Project
/**
* Adds exactly one maplibre-compose native runtime — the one matching this build host — as `runtimeOnly`.
*
* Each runtime artifact carries that platform's maplibre-native blob, so adding them all would bloat every
* distribution with four unusable copies. Upstream publishes no macos-x64 artifact, which matches our release matrix;
* on an Intel Mac no runtime is added and the map degrades to its engine-unavailable state at runtime, rather than
* failing every Gradle task at configuration time for a host that only builds the Android app.
* Shared by `:desktopApp` and `:marketing-screenshots`, the two JVM modules that draw a MapLibre map.
*/
fun Project.maplibreDesktopRuntime() {
val osName = providers.systemProperty("os.name").get().lowercase()
val osArch = providers.systemProperty("os.arch").get().lowercase()
val isArm = osArch.contains("aarch64") || osArch.contains("arm64")
if (osName.contains("mac") && !isArm) {
logger.warn("maplibre-compose publishes no macOS x64 runtime; the desktop map is unavailable on this host.")
return
}
val alias =
when {
osName.contains("mac") -> "maplibre-compose-runtime-metal-macos-arm64"
osName.contains("win") && isArm -> "maplibre-compose-runtime-vulkan-windows-arm64"
osName.contains("win") -> "maplibre-compose-runtime-vulkan-windows-x64"
isArm -> "maplibre-compose-runtime-vulkan-linux-arm64"
else -> "maplibre-compose-runtime-vulkan-linux-x64"
}
dependencies.add("runtimeOnly", libs.library(alias))
}
+10 -3
View File
@@ -86,7 +86,10 @@ complexity:
ignoreOverloaded: false
CyclomaticComplexMethod:
active: true
allowedComplexity: 15
# 35, not 15: Wire's buildersOnly protos are constructed through
# `Builder().also { }`, so a settings screen scores one point per row it
# writes without gaining a branch. MQTTConfigScreen reads 33 that way.
allowedComplexity: 35
ignoreSingleWhenExpression: false
ignoreSimpleWhenEntries: false
ignoreNestingFunctions: false
@@ -106,10 +109,13 @@ complexity:
ignoredLabels: []
LargeClass:
active: true
allowedLines: 600
# Same cause as CyclomaticComplexMethod: builder blocks are several lines
# where a named-argument constructor call was one.
allowedLines: 1100
LongMethod:
active: true
allowedLines: 60
# Same cause as CyclomaticComplexMethod.
allowedLines: 70
ignoreAnnotated: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
LongParameterList:
active: true
@@ -691,6 +697,7 @@ style:
- '0'
- '1'
- '2'
ignoreAnnotated: [ 'Preview', 'PreviewLightDark', 'PreviewScreenSizes' ]
ignoreHashCodeFunction: true
ignorePropertyDeclaration: true
ignoreLocalVariableDeclaration: false
@@ -23,7 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
@Suppress("ReturnCount")
internal actual fun Peripheral.refreshGattCache(): Boolean {
// Direct 2-hop reflection on Kable 0.43.1 internals.
// Direct 2-hop reflection on Kable 0.45.0 internals.
// Path: BluetoothDeviceAndroidPeripheral.connection (MutableStateFlow<Connection?>) → .value → Connection.gatt
// Re-verify field names on Kable version bumps.
val gatt =
@@ -25,10 +25,10 @@ import com.juul.kable.ScannerBuilder
import com.juul.kable.toIdentifier
import org.meshtastic.core.model.util.anonymize
// Kable's default trySendBlocking can park the scan-callback (sometimes main) thread in dense BLE
// environments, causing ANRs; preConflate drops excess advertisements instead (kable#654).
// The scan callback never blocks; a capacity of 1 conflates, so a dense BLE environment cannot back
// advertisements up behind a slow collector.
internal actual fun ScannerBuilder.platformScanConfig() {
preConflate = true
bufferCapacity = 1
}
/** Android's scanner filters on address in hardware, so Kable's `Filter.Address` works natively here. */
@@ -1,54 +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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.state
import kotlinx.atomicfu.atomic
import org.koin.core.annotation.Single
/**
* Marks a USB firmware-maintenance sequence as in flight, so the radio transport does not fight it for the device.
*
* A factory erase leaves the device enumerating as a bare CDC port with no Meshtastic protocol on it. The serial
* transport will happily bind to that — its device lookup falls back to whatever probed device it can find, not just
* the saved address — and the environmental-recovery listeners re-enter transport startup whenever Bluetooth or the
* network flips. Both would claim the port the maintenance flow needs.
*
* Lives in `:core:common` because the two parties sit in modules that cannot see each other: the flow that takes the
* lock is in `:feature:firmware`, and the code that must respect it is in `:core:service`.
*
* The consequence of ignoring the lock is bounded rather than destructive — a transport that claims the port asserts
* DTR itself, which happens to unblock the erase — but it makes the flow's own signal unreliable, and a mesh handshake
* against erase firmware is noise nobody needs to debug.
*/
@Single
class FirmwareMaintenanceLock {
private val active = atomic(false)
/** True while a maintenance sequence holds the lock. */
val isActive: Boolean
get() = active.value
/** Takes the lock. Idempotent — re-taking an already-held lock is a no-op. */
fun acquire() {
active.value = true
}
/** Releases the lock. Must run even on failure, so callers use `try`/`finally`. */
fun release() {
active.value = false
}
}
@@ -0,0 +1,129 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.state
import kotlinx.atomicfu.atomic
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.Single
/**
* A long-running operation that owns the radio and must not be interrupted halfway.
*
* [suppressesTransport] is per-operation rather than blanket because the operations genuinely differ: a USB factory
* erase needs the transport to stay off the port, while a discovery scan needs the transport to keep running.
*/
enum class RadioOperation(val suppressesTransport: Boolean) {
/**
* A USB firmware-maintenance sequence (factory erase).
*
* A factory erase leaves the device enumerating as a bare CDC port with no Meshtastic protocol on it. The serial
* transport will happily bind to that — its device lookup falls back to whatever probed device it can find, not
* just the saved address — and the environmental-recovery listeners re-enter transport startup whenever Bluetooth
* or the network flips. Both would claim the port the maintenance flow needs.
*/
FirmwareMaintenance(suppressesTransport = true),
/**
* An OTA/DFU flash.
*
* Does not suppress the transport: the flow already frees it by deselecting the device
* (`radioController.setDeviceAddress("n")`), and the post-flash verification deliberately reconnects the radio, so
* suppression would block the very step it is meant to protect.
*/
FirmwareUpdate(suppressesTransport = false),
/** A Local Mesh Discovery preset sweep. Needs the transport, so it must never suppress it. */
DiscoveryScan(suppressesTransport = false),
}
/**
* One holder's claim on a [RadioOperation]. Releasing a lease affects only that holder.
*
* Identity matters: two concurrent claims on the same operation produce two distinct leases, so a late release from an
* abandoned holder cannot retire a live one.
*/
class OperationLease internal constructor(internal val id: Long, val operation: RadioOperation)
/**
* Tracks which [RadioOperation]s are in flight, so the rest of the app can keep out of their way.
*
* Three consumers, which is why one lock replaced the single-purpose `FirmwareMaintenanceLock` it grew out of:
* `SharedRadioInterfaceService` reads [suppressesTransport] to stay off the device, `MeshService` reads [holders] to
* refuse to stop itself and to hold a wake lock, and the service notification reads it to say what is running.
*
* Holders are tracked as individual leases rather than a set of operation types. The set was wrong: a caller that
* cancels a job and immediately starts a replacement — which `FirmwareUpdateViewModel` does, without joining — lets the
* cancelled job's `finally` run *after* the replacement has acquired, and a shared entry would be retired out from
* under the live holder. Two holders of the same operation are now independent.
*
* Lives in `:core:common` because the parties sit in modules that cannot see each other: the flows that take an
* operation are in `:feature:firmware` and `:feature:discovery`, and the code that must respect it is in
* `:core:service`.
*
* Process-local by design. A process death drops every lease, which is correct — nothing is in flight any more — but it
* means this narrows the window in which an interrupted flash can strand a device rather than closing it.
*/
@Single
class RadioOperationLock {
private val nextLeaseId = atomic(0L)
private val _holders = MutableStateFlow<Map<Long, RadioOperation>>(emptyMap())
/** Every live lease, keyed by lease id. Emits on every acquire and release. */
val holders: StateFlow<Map<Long, RadioOperation>> = _holders.asStateFlow()
/** The distinct operations currently in flight. */
val activeOperations: Set<RadioOperation>
get() = _holders.value.values.toSet()
/** True while any operation is in flight. */
val isActive: Boolean
get() = _holders.value.isNotEmpty()
/** True while an operation that needs the transport off the device is in flight. */
val suppressesTransport: Boolean
get() = _holders.value.values.any(RadioOperation::suppressesTransport)
/** Takes [operation] and returns the lease that holds it. Each call is a distinct holder. */
fun acquire(operation: RadioOperation): OperationLease {
val lease = OperationLease(nextLeaseId.incrementAndGet(), operation)
_holders.update { it + (lease.id to operation) }
return lease
}
/**
* Releases [lease]. Idempotent, and null-safe so a caller can release a hold it may never have taken.
*
* Releasing a stale lease is harmless: its id is already gone, and other holders of the same operation keep theirs.
*/
fun release(lease: OperationLease?) {
val id = lease?.id ?: return
_holders.update { it - id }
}
/** Runs [block] with [operation] held, releasing that exact lease even on failure or cancellation. */
suspend fun <T> withOperation(operation: RadioOperation, block: suspend () -> T): T {
val lease = acquire(operation)
return try {
block()
} finally {
release(lease)
}
}
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.common.state
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RadioOperationLockTest {
@Test
fun startsIdle() {
val lock = RadioOperationLock()
assertFalse(lock.isActive)
assertFalse(lock.suppressesTransport)
assertEquals(emptySet(), lock.activeOperations)
}
@Test
fun releasingAnUnheldLeaseIsSafe() {
val lock = RadioOperationLock()
lock.release(null)
val lease = lock.acquire(RadioOperation.FirmwareUpdate)
lock.release(lease)
lock.release(lease)
assertFalse(lock.isActive)
}
@Test
fun twoHoldersOfTheSameOperationAreIndependent() {
// The firmware view model cancels an update job and starts a replacement without joining, so the cancelled
// job's release can land after the replacement has acquired. The late release must not retire the live holder.
val lock = RadioOperationLock()
val abandoned = lock.acquire(RadioOperation.FirmwareUpdate)
val replacement = lock.acquire(RadioOperation.FirmwareUpdate)
lock.release(abandoned)
assertTrue(lock.isActive, "the replacement holder must still hold the operation")
assertEquals(setOf(RadioOperation.FirmwareUpdate), lock.activeOperations)
lock.release(replacement)
assertFalse(lock.isActive)
}
@Test
fun differentOperationsDoNotClobberEachOther() {
val lock = RadioOperationLock()
val update = lock.acquire(RadioOperation.FirmwareUpdate)
lock.acquire(RadioOperation.DiscoveryScan)
lock.release(update)
assertEquals(setOf(RadioOperation.DiscoveryScan), lock.activeOperations)
}
@Test
fun onlyMaintenanceSuppressesTheTransport() {
val lock = RadioOperationLock()
lock.acquire(RadioOperation.DiscoveryScan)
assertFalse(lock.suppressesTransport, "a scan needs the transport running")
lock.acquire(RadioOperation.FirmwareUpdate)
assertFalse(lock.suppressesTransport, "an OTA frees the transport by deselecting, not by suppression")
val maintenance = lock.acquire(RadioOperation.FirmwareMaintenance)
assertTrue(lock.suppressesTransport)
lock.release(maintenance)
assertFalse(lock.suppressesTransport, "suppression must end with the maintenance pass")
}
@Test
fun suppressionSurvivesAnotherMaintenanceHolder() {
val lock = RadioOperationLock()
val first = lock.acquire(RadioOperation.FirmwareMaintenance)
lock.acquire(RadioOperation.FirmwareMaintenance)
lock.release(first)
assertTrue(lock.suppressesTransport, "the second maintenance holder still needs the transport off the port")
}
@Test
fun withOperationReleasesOnSuccess() = runTest {
val lock = RadioOperationLock()
assertEquals("done", lock.withOperation(RadioOperation.FirmwareUpdate) { "done" })
assertFalse(lock.isActive)
}
@Test
fun withOperationReleasesOnFailure() = runTest {
val lock = RadioOperationLock()
assertFailsWith<IllegalStateException> {
lock.withOperation(RadioOperation.FirmwareUpdate) { error("flash failed") }
}
assertFalse(lock.isActive, "a failed operation must not leak the lock")
}
@Test
fun withOperationReleasesOnCancellation() = runTest {
val lock = RadioOperationLock()
assertFailsWith<CancellationException> {
lock.withOperation(RadioOperation.DiscoveryScan) { throw CancellationException("stopped") }
}
assertFalse(lock.isActive, "a cancelled operation must not leak the lock")
}
@Test
fun withOperationCancellationLeavesAConcurrentHolderIntact() = runTest {
val lock = RadioOperationLock()
val other = lock.acquire(RadioOperation.FirmwareUpdate)
assertFailsWith<CancellationException> {
lock.withOperation(RadioOperation.FirmwareUpdate) { throw CancellationException("abandoned") }
}
assertTrue(lock.isActive, "the surviving holder keeps the operation")
lock.release(other)
assertFalse(lock.isActive)
}
@Test
fun holdersFlowTracksAcquireAndRelease() = runTest {
val lock = RadioOperationLock()
assertEquals(emptyMap(), lock.holders.value)
lock.withOperation(RadioOperation.DiscoveryScan) {
assertEquals(listOf(RadioOperation.DiscoveryScan), lock.holders.value.values.toList())
}
assertEquals(emptyMap(), lock.holders.value)
}
}
@@ -37,6 +37,11 @@ actual object DateFormatter {
private val shortDateTimeFormatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.MEDIUM)
// A localized formatter binds the default locale of the moment it was built, and the desktop app changes the
// default locale after this object exists (the language preference resolves in composition), so each call
// rebinds to the current one; withLocale is a cheap copy.
private fun DateTimeFormatter.localized(): DateTimeFormatter = withLocale(Locale.getDefault())
actual fun formatRelativeTime(timestampMillis: Long): String {
val deltaMillis = nowMillis - timestampMillis
val absDeltaMillis = abs(deltaMillis)
@@ -51,29 +56,29 @@ actual object DateFormatter {
}
actual fun formatDateTime(timestampMillis: Long): String =
shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
shortDateTimeFormatter.localized().format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatShortDate(timestampMillis: Long): String {
val isWithin24Hours = (nowMillis - timestampMillis) <= DAY_MILLIS
val zonedDateTime = java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId)
return if (isWithin24Hours) {
shortTimeFormatter.format(zonedDateTime)
shortTimeFormatter.localized().format(zonedDateTime)
} else {
shortDateFormatter.format(zonedDateTime)
shortDateFormatter.localized().format(zonedDateTime)
}
}
actual fun formatTime(timestampMillis: Long): String =
shortTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
shortTimeFormatter.localized().format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatTimeWithSeconds(timestampMillis: Long): String =
mediumTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
mediumTimeFormatter.localized().format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDate(timestampMillis: Long): String =
shortDateFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
shortDateFormatter.localized().format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
actual fun formatDateTimeShort(timestampMillis: Long): String =
shortDateTimeFormatter.format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
shortDateTimeFormatter.localized().format(java.time.Instant.ofEpochMilli(timestampMillis).atZone(zoneId))
}
// No ICU measurement data on the JVM, so the region table in core:common is the source. Desktop
@@ -29,7 +29,7 @@ interface NodeInfoReadDataSource {
fun getNodesFlow(
sort: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
onlyDirect: Boolean,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>>
@@ -16,6 +16,9 @@
*/
package org.meshtastic.core.data.datasource
import androidx.room3.immediateTransaction
import androidx.room3.useWriterConnection
import co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
@@ -24,9 +27,11 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.entity.ChannelSetEntity
import org.meshtastic.core.database.retryOnDbPoolFailure
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.util.planChannelReconciliation
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
@@ -54,13 +59,37 @@ class SwitchingChannelSetDataSource(
dbManager
.observeCurrentDb { db -> db.channelSetDao().observe() }
.retryOnDbPoolFailure("channelSet")
.map { entity -> entity?.channelSet ?: ChannelSet() }
.map { entity -> entity?.channelSet ?: ChannelSet.Builder().build() }
.distinctUntilChanged()
suspend fun clearChannelSet() {
// Must take writeMutex like mutate() -- otherwise a concurrent mutate() can read the row before this delete,
// then upsert it back afterwards, silently undoing the clear.
withContext(dispatchers.io) { writeMutex.withLock { dbManager.withDb { it.channelSetDao().clear() } } }
withContext(dispatchers.io) {
writeMutex.withLock {
dbManager.withDb { db ->
val dao = db.channelSetDao()
val existing = dao.get()
val empty = ChannelSet.ADAPTER.encode(ChannelSet.Builder().build())
when {
existing == null -> Unit
// Keep the baseline: the messages did not move because the cache was dropped.
existing.lastReconciled != null -> dao.clearRetainingBaseline(empty)
// No baseline yet, but a cached set from before this column existed. Adopt it on the way out,
// so the first handshake after upgrading can still tell whether the channels changed while
// the app was away instead of silently accepting whatever the radio now reports.
existing.channelSet.settings.isNotEmpty() -> {
dao.setLastReconciled(ChannelSetEntity.encodeBaseline(existing.channelSet))
dao.clearRetainingBaseline(empty)
}
else -> dao.clear()
}
}
}
}
}
/** Replaces all [ChannelSettings] in a single atomic operation. */
@@ -68,23 +97,53 @@ class SwitchingChannelSetDataSource(
updateChannelSet(settingsList = settingsList, loraConfig = null)
}
/** Atomically updates supplied [ChannelSet] fields while preserving fields omitted by the caller. */
/**
* Atomically updates supplied [ChannelSet] fields while preserving fields omitted by the caller.
*
* Conversations are reconciled in the same lock, but **only when [settingsList] is supplied**. A caller that passes
* the whole channel list is stating a complete set — a manual channel edit, a scanned channel URL, a profile
* install — which is the only safe moment to decide a channel has gone. A LoRa-only write is not: the radio sends
* its LoRa config during the handshake, before any channel has arrived, so reconciling there would see an empty
* channel list and archive every conversation on the device on every reconnect. Those changes are picked up by
* [reconcileConversations] once the handshake completes, which a preset change always triggers because the radio
* reboots.
*/
suspend fun updateChannelSet(settingsList: List<ChannelSettings>?, loraConfig: Config.LoRaConfig?) {
mutate { current ->
current.copy(settings = settingsList ?: current.settings, lora_config = loraConfig ?: current.lora_config)
mutate(reconcile = settingsList != null) { current ->
current
.newBuilder()
.also { wb ->
wb.settings = settingsList ?: current.settings
wb.lora_config = loraConfig ?: current.lora_config
}
.build()
}
}
/**
* Re-keys stored conversations onto the current channel set, and records it as the new baseline.
*
* Conversations are keyed by channel index, so a slot that changes occupant shows the previous channel's history
* under the new channel until this runs. Idempotent: a second call with nothing changed does nothing.
*
* Called explicitly after the handshake because the handshake streams channels in one slot at a time (and its LoRa
* config arrives as its own write). Reconciling on each of those would see a channel momentarily missing, retire
* it, and then see it come back.
*/
suspend fun reconcileConversations() {
withContext(dispatchers.io) { writeMutex.withLock { dbManager.withDb { db -> db.reconcile() } } }
}
/** Places [channel]'s settings at its index, resizing with blank channels to fill any gap (parity with legacy). */
suspend fun updateChannelSettings(channel: Channel) {
if (channel.role == Channel.Role.DISABLED) return
mutate { current ->
val settings = current.settings.toMutableList()
while (settings.size <= channel.index) {
settings.add(ChannelSettings())
settings.add(ChannelSettings.Builder().build())
}
settings[channel.index] = channel.settings ?: ChannelSettings()
current.copy(settings = settings)
settings[channel.index] = channel.settings ?: ChannelSettings.Builder().build()
current.newBuilder().also { wb -> wb.settings = settings }.build()
}
}
@@ -92,15 +151,53 @@ class SwitchingChannelSetDataSource(
updateChannelSet(settingsList = null, loraConfig = config)
}
private suspend fun mutate(transform: (ChannelSet) -> ChannelSet) {
private suspend fun mutate(reconcile: Boolean = false, transform: (ChannelSet) -> ChannelSet) {
withContext(dispatchers.io) {
writeMutex.withLock {
dbManager.withDb { db ->
val dao = db.channelSetDao()
val current = dao.get()?.channelSet ?: ChannelSet()
dao.upsert(ChannelSetEntity(channelSet = transform(current)))
val existing = dao.get()
val current = existing?.channelSet ?: ChannelSet.Builder().build()
dao.upsert(
ChannelSetEntity(channelSet = transform(current), lastReconciled = existing?.lastReconciled),
)
if (reconcile) db.reconcile()
}
}
}
}
/**
* Runs one reconciliation pass against the stored channel set. Caller holds [writeMutex].
*
* The re-key and the new baseline commit together. Apply-then-record as two transactions would, on a crash between
* them, recompute the identical plan next time and apply it to rows that had already moved — swapping a pair of
* channels back, or sweeping the *new* occupant's messages into the previous one's archive.
*/
private suspend fun MeshtasticDatabase.reconcile() {
useWriterConnection { transactor ->
transactor.immediateTransaction {
val dao = channelSetDao()
val entity = dao.get() ?: return@immediateTransaction
val current = entity.channelSet
val baseline = entity.lastReconciledChannelSet
// No baseline yet (fresh install, or upgrading from before this existed): adopt the current set as
// the starting point. Messages already misfiled by an earlier switch stay where they are -- there is
// nothing recorded to tell us where they belonged.
if (baseline != null) {
val changes =
planChannelReconciliation(
old = baseline,
new = current,
retiredTokens = packetDao().getRetiredContactTokens(),
)
if (changes.isNotEmpty()) {
Logger.i { "Re-keying ${changes.size} conversation(s) after a channel-set change" }
packetDao().applyChannelReconciliation(changes)
}
}
dao.setLastReconciled(ChannelSetEntity.encodeBaseline(current))
}
}
}
}
@@ -38,7 +38,7 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) :
override fun getNodesFlow(
sort: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
onlyDirect: Boolean,
lastHeardMin: Int,
): Flow<List<NodeWithRelations>> = dbManager
.observeCurrentDb { db ->
@@ -46,7 +46,7 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) :
.getNodes(
sort = sort,
includeUnknown = includeUnknown,
hopsAwayMax = hopsAwayMax,
onlyDirect = onlyDirect,
lastHeardMin = lastHeardMin,
)
}
@@ -81,8 +81,8 @@ class CommandSenderImpl(
) : CommandSender {
private val currentPacketId = atomic(Random(nowMillis).nextLong().absoluteValue)
private val localConfig = MutableStateFlow(LocalConfig())
private val channelSet = MutableStateFlow(ChannelSet())
private val localConfig = MutableStateFlow(LocalConfig.Builder().build())
private val channelSet = MutableStateFlow(ChannelSet.Builder().build())
init {
radioConfigRepository.localConfigFlow.onEach { localConfig.value = it }.launchIn(scope)
@@ -143,12 +143,14 @@ class CommandSenderImpl(
// Use Wire extension for accurate size validation
val data =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = bytes,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
)
Data.Builder()
.also { wb ->
wb.portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP
wb.payload = bytes
wb.reply_id = p.replyId ?: 0
wb.emoji = p.emoji
}
.build()
if (!Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)) {
val actualSize = Data.ADAPTER.encodedSize(data)
@@ -175,12 +177,14 @@ class CommandSenderImpl(
hopLimit = if (p.hopLimit > 0) p.hopLimit else computeHopLimit(),
channel = p.channel,
decoded =
Data(
portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP,
payload = p.bytes ?: ByteString.EMPTY,
reply_id = p.replyId ?: 0,
emoji = p.emoji,
),
Data.Builder()
.also { wb ->
wb.portnum = PortNum.fromValue(p.dataType) ?: PortNum.UNKNOWN_APP
wb.payload = p.bytes ?: ByteString.EMPTY
wb.reply_id = p.replyId ?: 0
wb.emoji = p.emoji
}
.build(),
)
return packetHandler.sendToRadio(meshPacket)
}
@@ -204,7 +208,8 @@ class CommandSenderImpl(
to = destNum,
id = requestId.nonZeroRequestId(),
wantResponse = wantResponse,
adminMessage = initFn().copy(session_passkey = sessionManager.getPasskey(destNum)),
adminMessage =
initFn().newBuilder().also { wb -> wb.session_passkey = sessionManager.getPasskey(destNum) }.build(),
)
override suspend fun sendAdmin(destNum: Int, requestId: Int, wantResponse: Boolean, initFn: () -> AdminMessage) {
@@ -226,9 +231,10 @@ class CommandSenderImpl(
}
override fun sendAdminImmediate(destNum: Int, initFn: () -> AdminMessage) {
val adminMsg = initFn().copy(session_passkey = sessionManager.getPasskey(destNum))
val adminMsg =
initFn().newBuilder().also { wb -> wb.session_passkey = sessionManager.getPasskey(destNum) }.build()
val packet = buildAdminPacket(to = destNum, adminMessage = adminMsg)
packetHandler.sendToRadio(ToRadio(packet = packet))
packetHandler.sendToRadio(ToRadio.Builder().also { wb -> wb.packet = packet }.build())
}
override suspend fun sendAdminAwaitResult(
@@ -250,11 +256,13 @@ class CommandSenderImpl(
channel = if (destNum == null) 0 else getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = pos.encode().toByteString(),
want_response = wantResponse,
),
Data.Builder()
.also { wb ->
wb.portnum = PortNum.POSITION_APP
wb.payload = pos.encode().toByteString()
wb.want_response = wantResponse
}
.build(),
),
"Position update",
)
@@ -265,23 +273,27 @@ class CommandSenderImpl(
override suspend fun requestPosition(destNum: Int, currentPosition: Position) {
val meshPosition =
ProtoPosition(
latitude_i = Position.degI(currentPosition.latitude),
longitude_i = Position.degI(currentPosition.longitude),
altitude = currentPosition.altitude,
time = (nowMillis / 1000L).toInt(),
)
ProtoPosition.Builder()
.also { wb ->
wb.latitude_i = Position.degI(currentPosition.latitude)
wb.longitude_i = Position.degI(currentPosition.longitude)
wb.altitude = currentPosition.altitude
wb.time = (nowMillis / MILLIS_PER_SECOND).toInt()
}
.build()
enqueueOrThrow(
buildMeshPacket(
to = destNum,
channel = getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
portnum = PortNum.POSITION_APP,
payload = meshPosition.encode().toByteString(),
want_response = true,
),
Data.Builder()
.also { wb ->
wb.portnum = PortNum.POSITION_APP
wb.payload = meshPosition.encode().toByteString()
wb.want_response = true
}
.build(),
),
"Position request",
)
@@ -309,11 +321,13 @@ class CommandSenderImpl(
to = destNum,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NODEINFO_APP,
want_response = true,
payload = myNode.user.encode().toByteString(),
),
Data.Builder()
.also { wb ->
wb.portnum = PortNum.NODEINFO_APP
wb.want_response = true
wb.payload = myNode.user.encode().toByteString()
}
.build(),
),
"User-info request",
)
@@ -327,7 +341,14 @@ class CommandSenderImpl(
wantAck = true,
id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.TRACEROUTE_APP
wb.want_response = true
wb.dest = destNum
}
.build(),
),
"Traceroute request",
)
@@ -361,18 +382,22 @@ class CommandSenderImpl(
if (type == TelemetryType.PAX) {
portNum = PortNum.PAXCOUNTER_APP
payloadBytes = Paxcount().encode().toByteString()
payloadBytes = Paxcount.Builder().build().encode().toByteString()
} else {
portNum = PortNum.TELEMETRY_APP
payloadBytes =
Telemetry(
device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics() else null,
environment_metrics = if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics() else null,
air_quality_metrics = if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics() else null,
power_metrics = if (type == TelemetryType.POWER) PowerMetrics() else null,
local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats() else null,
host_metrics = if (type == TelemetryType.HOST) HostMetrics() else null,
)
Telemetry.Builder()
.also { wb ->
wb.device_metrics = if (type == TelemetryType.DEVICE) DeviceMetrics.Builder().build() else null
wb.environment_metrics =
if (type == TelemetryType.ENVIRONMENT) EnvironmentMetrics.Builder().build() else null
wb.air_quality_metrics =
if (type == TelemetryType.AIR_QUALITY) AirQualityMetrics.Builder().build() else null
wb.power_metrics = if (type == TelemetryType.POWER) PowerMetrics.Builder().build() else null
wb.local_stats = if (type == TelemetryType.LOCAL_STATS) LocalStats.Builder().build() else null
wb.host_metrics = if (type == TelemetryType.HOST) HostMetrics.Builder().build() else null
}
.build()
.encode()
.toByteString()
}
@@ -382,7 +407,15 @@ class CommandSenderImpl(
to = destNum,
id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
decoded =
Data.Builder()
.also { wb ->
wb.portnum = portNum
wb.payload = payloadBytes
wb.want_response = true
wb.dest = destNum
}
.build(),
),
"Telemetry request",
expectedConnectionVersion,
@@ -399,20 +432,25 @@ class CommandSenderImpl(
?: run {
val oneHour = 1.hours.inWholeMinutes.toInt()
Logger.d { "No stored neighbor info from connected radio, sending dummy data" }
NeighborInfo(
node_id = myNum,
last_sent_by_id = myNum,
node_broadcast_interval_secs = oneHour,
neighbors =
listOf(
Neighbor(
node_id = 0, // Dummy node ID that can be intercepted
snr = 0f,
last_rx_time = (nowMillis / 1000L).toInt(),
node_broadcast_interval_secs = oneHour,
),
),
)
NeighborInfo.Builder()
.also { wb ->
wb.node_id = myNum
wb.last_sent_by_id = myNum
wb.node_broadcast_interval_secs = oneHour
wb.neighbors =
listOf(
Neighbor.Builder()
.also { wb ->
wb.node_id = 0
// Dummy node ID that can be intercepted
wb.snr = 0f
wb.last_rx_time = (nowMillis / MILLIS_PER_SECOND).toInt()
wb.node_broadcast_interval_secs = oneHour
}
.build(),
)
}
.build()
}
// Send the neighbor info from our connected radio to ourselves (simulated)
@@ -422,11 +460,13 @@ class CommandSenderImpl(
id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
payload = neighborInfoToSend.encode().toByteString(),
want_response = true,
),
Data.Builder()
.also { wb ->
wb.portnum = PortNum.NEIGHBORINFO_APP
wb.payload = neighborInfoToSend.encode().toByteString()
wb.want_response = true
}
.build(),
)
} else {
// Send request to remote
@@ -435,7 +475,14 @@ class CommandSenderImpl(
wantAck = true,
id = effectiveRequestId,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.NEIGHBORINFO_APP
wb.want_response = true
wb.dest = destNum
}
.build(),
)
}
enqueueOrThrow(packet, "Neighbor-info request")
@@ -456,32 +503,46 @@ class CommandSenderImpl(
0
}
val lockdownAuth =
LockdownAuth(
passphrase = passphrase.encodeToByteArray().toByteString(),
boots_remaining = boots.coerceAtLeast(0),
valid_until_epoch = validUntilEpoch,
max_session_seconds = maxSessionSeconds.coerceAtLeast(0),
disable = disable,
)
return sendLockdownAdmin(AdminMessage(lockdown_auth = lockdownAuth))
LockdownAuth.Builder()
.also { wb ->
wb.passphrase = passphrase.encodeToByteArray().toByteString()
wb.boots_remaining = boots.coerceAtLeast(0)
wb.valid_until_epoch = validUntilEpoch
wb.max_session_seconds = maxSessionSeconds.coerceAtLeast(0)
wb.disable = disable
}
.build()
return sendLockdownAdmin(AdminMessage.Builder().also { wb -> wb.lockdown_auth = lockdownAuth }.build())
}
override fun sendLockNow(): Boolean = sendLockdownAdmin(AdminMessage(lockdown_auth = LockdownAuth(lock_now = true)))
override fun sendLockNow(): Boolean = sendLockdownAdmin(
AdminMessage.Builder()
.also { wb -> wb.lockdown_auth = LockdownAuth.Builder().also { wb -> wb.lock_now = true }.build() }
.build(),
)
private fun sendLockdownAdmin(adminMessage: AdminMessage): Boolean {
val myNum = nodeManager.myNodeNum.value ?: return false
val packet =
MeshPacket(
to = myNum,
id = generatePacketId(),
channel = 0,
want_ack = true,
hop_limit = DEFAULT_HOP_LIMIT,
hop_start = DEFAULT_HOP_LIMIT,
priority = MeshPacket.Priority.RELIABLE,
decoded = Data(portnum = PortNum.ADMIN_APP, payload = adminMessage.encode().toByteString()),
)
return packetHandler.trySendToRadio(ToRadio(packet = packet))
MeshPacket.Builder()
.also { wb ->
wb.to = myNum
wb.id = generatePacketId()
wb.channel = 0
wb.want_ack = true
wb.hop_limit = DEFAULT_HOP_LIMIT
wb.hop_start = DEFAULT_HOP_LIMIT
wb.priority = MeshPacket.Priority.RELIABLE
wb.decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.ADMIN_APP
wb.payload = adminMessage.encode().toByteString()
}
.build()
}
.build()
return packetHandler.trySendToRadio(ToRadio.Builder().also { wb -> wb.packet = packet }.build())
}
fun resolveNodeNum(address: NodeAddress): Int = when (address) {
@@ -524,19 +585,21 @@ class CommandSenderImpl(
actualChannel = 0
}
return MeshPacket(
from = nodeManager.myNodeNum.value ?: 0,
to = to,
id = id,
want_ack = wantAck,
hop_limit = actualHopLimit,
hop_start = actualHopLimit,
priority = priority,
pki_encrypted = pkiEncrypted,
public_key = publicKey,
channel = actualChannel,
decoded = decoded,
)
return MeshPacket.Builder()
.also { wb ->
wb.from = nodeManager.myNodeNum.value ?: 0
wb.to = to
wb.id = id
wb.want_ack = wantAck
wb.hop_limit = actualHopLimit
wb.hop_start = actualHopLimit
wb.priority = priority
wb.pki_encrypted = pkiEncrypted
wb.public_key = publicKey
wb.channel = actualChannel
wb.decoded = decoded
}
.build()
}
private fun buildAdminPacket(
@@ -552,11 +615,13 @@ class CommandSenderImpl(
channel = getAdminChannelIndex(to),
priority = MeshPacket.Priority.RELIABLE,
decoded =
Data(
want_response = wantResponse,
portnum = PortNum.ADMIN_APP,
payload = adminMessage.encode().toByteString(),
),
Data.Builder()
.also { wb ->
wb.want_response = wantResponse
wb.portnum = PortNum.ADMIN_APP
wb.payload = adminMessage.encode().toByteString()
}
.build(),
)
companion object {
@@ -47,7 +47,11 @@ class DataLayerHeartbeatSender(private val packetHandler: PacketHandler) {
fun sendHeartbeat(tag: String = "handshake") {
try {
val n = nonce.getAndIncrement()
packetHandler.sendToRadio(ToRadio(heartbeat = Heartbeat(nonce = n)))
packetHandler.sendToRadio(
ToRadio.Builder()
.also { wb -> wb.heartbeat = Heartbeat.Builder().also { wb -> wb.nonce = n }.build() }
.build(),
)
Logger.d { "[$tag] Heartbeat enqueued (nonce=$n)" }
} catch (e: Exception) {
Logger.w(e) { "[$tag] Failed to enqueue heartbeat; proceeding" }
@@ -51,12 +51,19 @@ class HistoryManagerImpl(
historyReturnMax: Int,
): StoreAndForward {
val history =
StoreAndForward.History(
last_request = lastRequest.coerceAtLeast(0),
window = historyReturnWindow.coerceAtLeast(0),
history_messages = historyReturnMax.coerceAtLeast(0),
)
return StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, history = history)
StoreAndForward.History.Builder()
.also { wb ->
wb.last_request = lastRequest.coerceAtLeast(0)
wb.window = historyReturnWindow.coerceAtLeast(0)
wb.history_messages = historyReturnMax.coerceAtLeast(0)
}
.build()
return StoreAndForward.Builder()
.also { wb ->
wb.rr = StoreAndForward.RequestResponse.CLIENT_HISTORY
wb.history = history
}
.build()
}
fun resolveHistoryRequestParameters(window: Int, max: Int): Pair<Int, Int> {
@@ -100,13 +107,21 @@ class HistoryManagerImpl(
val accepted =
packetHandler.sendToRadioForConnection(
MeshPacket(
from = nodeNum,
to = nodeNum,
id = commandSender.generatePacketId(),
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()),
priority = MeshPacket.Priority.BACKGROUND,
),
MeshPacket.Builder()
.also { wb ->
wb.from = nodeNum
wb.to = nodeNum
wb.id = commandSender.generatePacketId()
wb.decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.STORE_FORWARD_APP
wb.payload = request.encode().toByteString()
}
.build()
wb.priority = MeshPacket.Priority.BACKGROUND
}
.build(),
expectedConnectionVersion,
)
if (!accepted) throw PacketQueueRejectedException("History replay")
@@ -282,6 +282,15 @@ class MeshConfigFlowManagerImpl(
private suspend fun installAndPublishNodeDatabase(state: HandshakeState.ReceivingNodeInfo) {
val session = state.session
val info = state.myNodeInfo
// Stage 1 streamed the radio's channels in one slot at a time, and its LoRa config arrived as a separate
// write, so no single one of those could tell a replaced channel from a half-downloaded set. Now that the
// whole set has landed, re-key the conversations once -- before Connected, so the list is never rendered
// with one channel's history under another's name.
safeCatching { radioConfigRepository.reconcileConversations() }
.onFailure { e -> Logger.e(e) { "Conversation reconciliation failed after handshake" } }
if (!isActiveSession(session)) return
val entities = mutableListOf<Node>()
state.nodes.forEach { nodeInfo ->
nodeManager.installNodeInfo(nodeInfo)
@@ -385,7 +394,7 @@ class MeshConfigFlowManagerImpl(
handshakeState.value = state.copy(metadata = metadata)
// Persist the metadata immediately, but never let a queued old-session write target the next
// session's selected database.
if (metadata != DeviceMetadata()) {
if (metadata != DeviceMetadata.Builder().build()) {
metadataNodeNum = state.rawMyNodeInfo.my_node_num
}
connectionManager.value.onHandshakeProgress()
@@ -51,10 +51,10 @@ class MeshConfigHandlerImpl(
private val scope: ServiceScope,
) : MeshConfigHandler {
private val _localConfig = MutableStateFlow(LocalConfig())
private val _localConfig = MutableStateFlow(LocalConfig.Builder().build())
override val localConfig = _localConfig.asStateFlow()
private val _moduleConfig = MutableStateFlow(LocalModuleConfig())
private val _moduleConfig = MutableStateFlow(LocalModuleConfig.Builder().build())
override val moduleConfig = _moduleConfig.asStateFlow()
init {
@@ -550,7 +550,7 @@ class MeshConnectionManagerImpl(
// packet - without this, signal-quality rating would silently blend the *previous* radio's noise floor into
// the new one's readings until it gets around to reporting its own. Clearing back to "no reading yet" makes
// that gap an explicit SNR-only fallback (design#15) instead of a stale, wrong number.
nodeRepository.updateLocalStats(LocalStats())
nodeRepository.updateLocalStats(LocalStats.Builder().build())
analytics.track(
EVENT_MESH_DISCONNECT,
@@ -563,7 +563,9 @@ class MeshConnectionManagerImpl(
override fun startConfigOnly() {
stageProgressSignals.value = 0
armStageGuard(1, HANDSHAKE_TIMEOUT_STAGE1)
packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.CONFIG_NONCE))
packetHandler.sendToRadio(
ToRadio.Builder().also { wb -> wb.want_config_id = HandshakeConstants.CONFIG_NONCE }.build(),
)
}
override fun clearRadioConfig() {
@@ -577,7 +579,9 @@ class MeshConnectionManagerImpl(
override fun startNodeInfoOnly() {
stageProgressSignals.value = 0
armStageGuard(2, HANDSHAKE_TIMEOUT_STAGE2)
packetHandler.sendToRadio(ToRadio(want_config_id = HandshakeConstants.NODE_INFO_NONCE))
packetHandler.sendToRadio(
ToRadio.Builder().also { wb -> wb.want_config_id = HandshakeConstants.NODE_INFO_NONCE }.build(),
)
}
override fun onRadioConfigLoaded() {
@@ -601,7 +605,9 @@ class MeshConnectionManagerImpl(
// config/node-info bursts avoids the GATT contention that pushed the old
// onRadioConfigLoaded-time send out of Stage 1. Must bypass the outbound packet queue:
// it only drains once Connected, which would hold this until after the backlog flush.
commandSender.sendAdminImmediate(myNodeNum) { AdminMessage(set_time_only = nowSeconds.toInt()) }
commandSender.sendAdminImmediate(myNodeNum) {
AdminMessage.Builder().also { wb -> wb.set_time_only = nowSeconds.toInt() }.build()
}
}
override suspend fun onNodeDbReady() {
@@ -645,7 +651,7 @@ class MeshConnectionManagerImpl(
expectedConnectionVersion = connectedLifecycle.version,
wantResponse = true,
) {
AdminMessage(get_owner_request = true)
AdminMessage.Builder().also { wb -> wb.get_owner_request = true }.build()
}
}
}
@@ -307,7 +307,7 @@ class MeshDataHandlerImpl(
// guard (0 means "use the default pushpin").
if (!u.icon.isValidCodePoint()) {
Logger.w { "Clearing an out-of-range waypoint icon code point (${u.icon})" }
dataPacket.bytes = Waypoint.ADAPTER.encode(u.copy(icon = 0)).toByteString()
dataPacket.bytes = Waypoint.ADAPTER.encode(u.newBuilder().also { wb -> wb.icon = 0 }.build()).toByteString()
}
val updateNotification = u.expire > nowSeconds.toInt()
radioInterfaceService.launchSessionWork(scope, session) {
@@ -356,10 +356,16 @@ 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.newBuilder().also { wb -> wb.public_key = ByteString.EMPTY }.build()
} else {
it
}
}
.let {
if (packet.via_mqtt == true && !it.long_name.endsWith(" (MQTT)")) {
it.copy(long_name = "${it.long_name} (MQTT)")
it.newBuilder().also { wb -> wb.long_name = "${it.long_name} (MQTT)" }.build()
} else {
it
}
@@ -600,7 +606,7 @@ class MeshDataHandlerImpl(
val contactKey = dataPacket.contactKey(myNodeNum)
val fromNode = nodeManager.nodeDBbyNodeNum[packet.from] ?: Node(num = packet.from)
val fromUser = fromNode.user.copy(id = fromNode.user.id.ifEmpty { fromId })
val fromUser = fromNode.user.newBuilder().also { wb -> wb.id = fromNode.user.id.ifEmpty { fromId } }.build()
val reaction =
Reaction(
@@ -115,7 +115,9 @@ class MeshMessageProcessorImpl(
val proto =
safeCatching { FromRadio.ADAPTER.decode(bytes) }
.getOrElse { primaryException ->
safeCatching { FromRadio(log_record = LogRecord.ADAPTER.decode(bytes)) }
safeCatching {
FromRadio.Builder().also { wb -> wb.log_record = LogRecord.ADAPTER.decode(bytes) }.build()
}
.getOrElse {
Logger.e(primaryException) {
"Failed to parse radio packet (len=${bytes.size}). Not a valid FromRadio or LogRecord."
@@ -184,7 +186,8 @@ class MeshMessageProcessorImpl(
/** Test seam for packet-only fixtures with explicit transport authority. */
internal suspend fun handleReceivedMeshPacket(packet: MeshPacket, myNodeNum: Int?, session: RadioSessionContext) {
// Single normalization point: every consumer downstream of this copy sees a stamped packet.
val preparedPacket = packet.copy(rx_time = packet.rxTimeOrNull() ?: nowSeconds.toInt())
val preparedPacket =
packet.newBuilder().also { wb -> wb.rx_time = packet.rxTimeOrNull() ?: nowSeconds.toInt() }.build()
// Require myNodeNum to be known before storing: processReceivedMeshPacket only keys a local packet under
// NODE_NUM_LOCAL when packet.from == myNodeNum. If myNodeNum is still null (early in a (re)connect, before
@@ -273,7 +276,7 @@ class MeshMessageProcessorImpl(
raw_message = packet.toString(),
fromNum = if (packet.from == myNodeNum) MeshLog.NODE_NUM_LOCAL else packet.from,
portNum = decoded.portnum.value,
fromRadio = FromRadio(packet = packet),
fromRadio = FromRadio.Builder().also { wb -> wb.packet = packet }.build(),
)
val logJob = insertMeshLog(log, session)
@@ -86,7 +86,11 @@ class MqttManagerImpl(
_proxyActive.value = true
mqttMessageFlow =
mqttRepository.proxyMessageFlow
.onEach { message -> packetHandler.sendToRadio(ToRadio(mqttClientProxyMessage = message)) }
.onEach { message ->
packetHandler.sendToRadio(
ToRadio.Builder().also { wb -> wb.mqttClientProxyMessage = message }.build(),
)
}
.catch { throwable ->
_proxyActive.value = false
// safeCatchingAll swallows the Skiko ExceptionInInitializerError that
@@ -378,11 +378,14 @@ class NodeManagerImpl(
*/
internal fun preservingKnownPrecision(incoming: ProtoPosition, stored: ProtoPosition): ProtoPosition =
if (isRedundantCoarsePosition(incoming, stored)) {
incoming.copy(
latitude_i = stored.latitude_i,
longitude_i = stored.longitude_i,
precision_bits = stored.precision_bits,
)
incoming
.newBuilder()
.also { wb ->
wb.latitude_i = stored.latitude_i
wb.longitude_i = stored.longitude_i
wb.precision_bits = stored.precision_bits
}
.build()
} else {
incoming
}
@@ -721,15 +724,18 @@ class NodeManagerImpl(
val newPos =
when {
isZeroPos ->
p.copy(
time = posTime,
latitude_i = node.position.latitude_i,
longitude_i = node.position.longitude_i,
altitude = p.altitude ?: node.position.altitude,
sats_in_view = p.sats_in_view,
)
p.newBuilder()
.also { wb ->
wb.time = posTime
wb.latitude_i = node.position.latitude_i
wb.longitude_i = node.position.longitude_i
wb.altitude = p.altitude ?: node.position.altitude
wb.sats_in_view = p.sats_in_view
}
.build()
else -> preservingKnownPrecision(p.copy(time = posTime), node.position)
else ->
preservingKnownPrecision(p.newBuilder().also { wb -> wb.time = posTime }.build(), node.position)
}
node.copy(position = newPos, lastHeard = newLastHeard)
@@ -743,6 +749,7 @@ class NodeManagerImpl(
telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) }
telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) }
telemetry.air_quality_metrics?.let { nextNode = nextNode.copy(airQualityMetrics = it) }
telemetry.soil_water_metrics?.let { nextNode = nextNode.copy(soilWaterMetrics = it) }
val telemetryTime = if (telemetry.time != 0) telemetry.time else node.lastHeard
val newLastHeard = clampTimestampToNow(maxOf(node.lastHeard, telemetryTime))
nextNode.copy(lastHeard = newLastHeard)
@@ -785,16 +792,23 @@ class NodeManagerImpl(
var next = node
val user = info.user
if (user != null && !shouldPreserveExistingUser(node.user, user)) {
var newUser = user.let { if (it.is_licensed == true) it.copy(public_key = ByteString.EMPTY) else it }
var newUser =
user.let {
if (it.is_licensed == true) {
it.newBuilder().also { wb -> wb.public_key = ByteString.EMPTY }.build()
} else {
it
}
}
if (info.via_mqtt && !newUser.long_name.endsWith(" (MQTT)")) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
newUser = newUser.newBuilder().also { wb -> wb.long_name = "${newUser.long_name} (MQTT)" }.build()
}
next = next.copy(user = newUser, publicKey = newUser.public_key)
}
info.position?.let { position ->
// The NodeDB snapshot from the connected device carries the coarsened position for nodes on a
// precision-limited channel; do not let it clobber a more precise coordinate we already hold (#6360).
val timed = position.copy(time = clampTimestampToNow(position.time))
val timed = position.newBuilder().also { wb -> wb.time = clampTimestampToNow(position.time) }.build()
next = next.copy(position = preservingKnownPrecision(timed, next.position))
}
// Firmware that predates the field never sends it, and a proto3 bool decodes as false - which would mark
@@ -1108,12 +1122,14 @@ class NodeManagerImpl(
return Node(
num = num,
user =
User(
id = userId,
long_name = "Meshtastic ${userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)}",
short_name = userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH),
hw_model = HardwareModel.UNSET,
),
User.Builder()
.also { wb ->
wb.id = userId
wb.long_name = "Meshtastic ${userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)}"
wb.short_name = userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)
wb.hw_model = HardwareModel.UNSET
}
.build(),
channel = channel,
)
}
@@ -1127,14 +1143,21 @@ class NodeManagerImpl(
node.copy(channel = channel, manuallyVerified = manuallyVerified)
} else {
val incomingKey = resolveValidatedPublicKeyHint(p.public_key)
val sanitizedUser = if (incomingKey == null) p.copy(public_key = ByteString.EMPTY) else p
// Prefer node.publicKey when valid (the authoritative stored key); fall back to node.user.public_key.
val existingKey = resolveNodePublicKeyHint(node)
val keyMatch = existingKey == null || existingKey == incomingKey
val newUser = if (keyMatch) sanitizedUser else sanitizedUser.copy(public_key = ByteString.EMPTY)
// Only two valid, different keys are a mismatch. A packet with no usable key says nothing about the one
// on file, so it neither flags nor clears anything, and the stored key stays.
val keyMismatch = existingKey != null && incomingKey != null && existingKey != incomingKey
// First-wins, matching the DAO and the firmware: a different key for a node we already hold one for is
// refused and recorded, never applied. Clearing the stored key here would break PKC direct messages to
// that contact on the word of whoever sent the substitute.
val keptKey = if (incomingKey == null || keyMismatch) existingKey else incomingKey
val newUser = p.newBuilder().also { wb -> wb.public_key = keptKey ?: ByteString.EMPTY }.build()
node.copy(
user = newUser,
publicKey = newUser.public_key,
keyMatch = node.keyMatch && !keyMismatch,
newPublicKey = if (keyMismatch) incomingKey else node.newPublicKey,
channel = channel,
manuallyVerified = manuallyVerified,
)
@@ -224,7 +224,7 @@ class PacketHandlerImpl(
raw_message = packet.toString(),
fromNum = MeshLog.NODE_NUM_LOCAL,
portNum = packet.decoded?.portnum?.value ?: 0,
fromRadio = FromRadio(packet = packet),
fromRadio = FromRadio.Builder().also { wb -> wb.packet = packet }.build(),
)
insertMeshLog(packetToSave)
}
@@ -680,7 +680,7 @@ class PacketHandlerImpl(
throw RadioNotConnectedException()
}
val departureEpoch = lifecycle.epochs.departures
val accepted = dispatchToRadio(ToRadio(packet = packet))
val accepted = dispatchToRadio(ToRadio.Builder().also { wb -> wb.packet = packet }.build())
pending.recordDispatch(accepted = accepted, departureEpoch = departureEpoch)
if (!accepted) pending.completeAll(AwaitedSendStatus.SEND_FAILED)
}
@@ -202,7 +202,17 @@ class StoreForwardPacketHandlerImpl(
if (s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) {
dataPacket.to = NodeAddress.ID_BROADCAST
}
val u = dataPacket.copy(bytes = s.text, dataType = PortNum.TEXT_MESSAGE_APP.value)
// Only a router-text replay travels under a fresh header id with the stored
// message's id in original_id; dedupe against that, and fall back when it is absent.
val isRouterText =
s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT ||
s.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST
val u =
dataPacket.copy(
bytes = s.text,
dataType = PortNum.TEXT_MESSAGE_APP.value,
id = s.original_id.takeIf { isRouterText && it != 0 } ?: dataPacket.id,
)
dataHandler.value.rememberDataPacket(u, myNodeNum, session = session)
}
@@ -67,7 +67,13 @@ class TelemetryPacketHandlerImpl(
val payload = packet.decoded?.payload ?: return
val telemetry =
(Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return).let {
if (it.time == 0) it.copy(time = (dataPacket.time.milliseconds.inWholeSeconds).toInt()) else it
if (it.time == 0) {
it.newBuilder()
.also { wb -> wb.time = (dataPacket.time.milliseconds.inWholeSeconds).toInt() }
.build()
} else {
it
}
}
Logger.d { "Telemetry from ${packet.from}: ${Telemetry.ADAPTER.toOneLiner(telemetry)}" }
val fromNum = packet.from
@@ -80,6 +86,7 @@ class TelemetryPacketHandlerImpl(
telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) }
telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) }
telemetry.air_quality_metrics?.let { nextNode = nextNode.copy(airQualityMetrics = it) }
telemetry.soil_water_metrics?.let { nextNode = nextNode.copy(soilWaterMetrics = it) }
val telemetryTime = if (telemetry.time != 0) telemetry.time else nextNode.lastHeard
val newLastHeard = clampTimestampToNow(maxOf(nextNode.lastHeard, telemetryTime))
nextNode.copy(lastHeard = newLastHeard)
@@ -147,7 +147,11 @@ class XModemManagerImpl(private val packetHandler: PacketHandler) : XModemManage
}
private fun sendControl(control: XModem.Control) {
packetHandler.sendToRadio(ToRadio(xmodemPacket = XModem(control = control)))
packetHandler.sendToRadio(
ToRadio.Builder()
.also { wb -> wb.xmodemPacket = XModem.Builder().also { wb -> wb.control = control }.build() }
.build(),
)
}
private fun reset() {
@@ -128,10 +128,13 @@ open class MeshLogRepositoryImpl(
if (decoded.want_response == true) return@runCatching null
val telemetry = Telemetry.ADAPTER.decode(decoded.payload)
telemetry.copy(
time = (log.received_date / MILLIS_PER_SEC).toInt(),
environment_metrics = telemetry.environment_metrics?.withSentinelsForAbsentReadings(),
)
telemetry
.newBuilder()
.also { wb ->
wb.time = (log.received_date / MILLIS_PER_SEC).toInt()
wb.environment_metrics = telemetry.environment_metrics?.withSentinelsForAbsentReadings()
}
.build()
}
.getOrNull()
@@ -247,19 +250,21 @@ open class MeshLogRepositoryImpl(
private fun EnvironmentMetrics.withSentinelsForAbsentReadings(): EnvironmentMetrics =
withLegacyOneWireTemperatures().withScalarSentinels().withChannelSentinels()
private fun EnvironmentMetrics.withScalarSentinels(): EnvironmentMetrics = copy(
temperature = temperature ?: Float.NaN,
relative_humidity = relative_humidity ?: Float.NaN,
soil_temperature = soil_temperature ?: Float.NaN,
barometric_pressure = barometric_pressure ?: Float.NaN,
gas_resistance = gas_resistance ?: Float.NaN,
voltage = voltage ?: Float.NaN,
current = current ?: Float.NaN,
lux = lux ?: Float.NaN,
uv_lux = uv_lux ?: Float.NaN,
iaq = iaq ?: Int.MIN_VALUE,
soil_moisture = soil_moisture ?: Int.MIN_VALUE,
)
private fun EnvironmentMetrics.withScalarSentinels(): EnvironmentMetrics = this.newBuilder()
.also { wb ->
wb.temperature = temperature ?: Float.NaN
wb.relative_humidity = relative_humidity ?: Float.NaN
wb.soil_temperature = soil_temperature ?: Float.NaN
wb.barometric_pressure = barometric_pressure ?: Float.NaN
wb.gas_resistance = gas_resistance ?: Float.NaN
wb.voltage = voltage ?: Float.NaN
wb.current = current ?: Float.NaN
wb.lux = lux ?: Float.NaN
wb.uv_lux = uv_lux ?: Float.NaN
wb.iaq = iaq ?: Int.MIN_VALUE
wb.soil_moisture = soil_moisture ?: Int.MIN_VALUE
}
.build()
private fun EnvironmentMetrics.withChannelSentinels(): EnvironmentMetrics =
(0 until TELEMETRY_CHANNEL_COUNT).fold(this) { metrics, channel ->
@@ -91,7 +91,7 @@ class NodeRepositoryImpl(
localStatsDataSource.localStatsFlow.stateIn(
processLifecycle.coroutineScope,
SharingStarted.Eagerly,
LocalStats(),
LocalStats.Builder().build(),
)
/** Update the cached local stats telemetry. */
@@ -176,10 +176,20 @@ class NodeRepositoryImpl(
fallbackId
}
return found?.copy(
long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong,
short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort,
) ?: User(id = userId, long_name = defaultLong, short_name = defaultShort)
return found
?.newBuilder()
?.also { wb ->
wb.long_name = found.long_name.takeIf { it.isNotBlank() } ?: defaultLong
wb.short_name = found.short_name.takeIf { it.isNotBlank() } ?: defaultShort
}
?.build()
?: User.Builder()
.also { wb ->
wb.id = userId
wb.long_name = defaultLong
wb.short_name = defaultShort
}
.build()
}
/** Returns a flow of nodes filtered and sorted according to the parameters. */
@@ -193,7 +203,7 @@ class NodeRepositoryImpl(
.getNodesFlow(
sort = sort.sqlValue,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
onlyDirect = onlyDirect,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.mapLatest { list -> list.map { it.toModel() }.filter { node -> node.matchesSearch(filter) } }
@@ -286,16 +296,28 @@ class NodeRepositoryImpl(
snr = snr,
rssi = rssi,
lastHeard = lastHeard,
deviceTelemetry = org.meshtastic.proto.Telemetry(device_metrics = deviceMetrics),
deviceTelemetry =
org.meshtastic.proto.Telemetry.Builder().also { wb -> wb.device_metrics = deviceMetrics }.build(),
channel = channel,
viaMqtt = viaMqtt,
hopsAway = hopsAway,
isFavorite = isFavorite,
isIgnored = isIgnored,
isMuted = isMuted,
environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics),
powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics),
airQualityTelemetry = org.meshtastic.proto.Telemetry(air_quality_metrics = airQualityMetrics),
environmentTelemetry =
org.meshtastic.proto.Telemetry.Builder()
.also { wb -> wb.environment_metrics = environmentMetrics }
.build(),
powerTelemetry =
org.meshtastic.proto.Telemetry.Builder().also { wb -> wb.power_metrics = powerMetrics }.build(),
airQualityTelemetry =
org.meshtastic.proto.Telemetry.Builder()
.also { wb -> wb.air_quality_metrics = airQualityMetrics }
.build(),
soilWaterTelemetry =
org.meshtastic.proto.Telemetry.Builder()
.also { wb -> wb.soil_water_metrics = soilWaterMetrics }
.build(),
paxcounter = paxcounter,
publicKey = publicKey,
notes = notes,
@@ -304,5 +326,7 @@ class NodeRepositoryImpl(
lastTransport = lastTransport,
signsPackets = signsPackets,
heardOnCurrentLora = heardOnCurrentLora,
keyMatch = keyMatch,
newPublicKey = newPublicKey,
)
}
@@ -44,7 +44,6 @@ import org.meshtastic.core.repository.PersistedPacket
import org.meshtastic.core.repository.PersistedPacketId
import org.meshtastic.core.repository.PersistedReaction
import org.meshtastic.core.repository.PersistedReactionId
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.core.database.entity.ContactSettings as ContactSettingsEntity
@@ -527,12 +526,6 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
withContext(dispatchers.io) { dbManager.withDb { it.packetDao().deleteAll() } }
}
override suspend fun migrateChannelsByPSK(oldSettings: List<ChannelSettings>, newSettings: List<ChannelSettings>) {
withContext(dispatchers.io) {
dbManager.withDb { it.packetDao().migrateChannelsByPSK(oldSettings, newSettings) }
}
}
override suspend fun updateFilteredBySender(senderId: String, filtered: Boolean) {
val pattern = "%\"from\":\"${senderId}\"%"
withContext(dispatchers.io) { dbManager.withDb { it.packetDao().updateFilteredBySender(pattern, filtered) } }
@@ -550,6 +543,7 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
isMuted = isMuted,
draft = draft,
pinned = pinned,
displayName = displayName,
)
private fun Reaction.toEntity(myNodeNum: Int) = RoomReaction(
@@ -73,6 +73,10 @@ open class RadioConfigRepositoryImpl(
channelSetDataSource.updateChannelSet(settingsList, loraConfig)
}
override suspend fun reconcileConversations() {
channelSetDataSource.reconcileConversations()
}
/**
* Updates the [ChannelSettings] list with the provided channel and returns the index of the admin channel after the
* update (if not found, returns 0).
@@ -182,20 +186,22 @@ open class RadioConfigRepositoryImpl(
localConfig,
localModuleConfig,
->
DeviceProfile(
long_name = node?.user?.long_name,
short_name = node?.user?.short_name,
channel_url = channels.getChannelUrl().toString(),
config = localConfig,
module_config = localModuleConfig,
is_unmessagable = node?.user?.is_unmessagable,
is_licensed = node?.user?.is_licensed,
fixed_position =
if (node != null && localConfig.position?.fixed_position == true) {
node.position
} else {
null
},
)
DeviceProfile.Builder()
.also { wb ->
wb.long_name = node?.user?.long_name
wb.short_name = node?.user?.short_name
wb.channel_url = channels.getChannelUrl().toString()
wb.config = localConfig
wb.module_config = localModuleConfig
wb.is_unmessagable = node?.user?.is_unmessagable
wb.is_licensed = node?.user?.is_licensed
wb.fixed_position =
if (node != null && localConfig.position?.fixed_position == true) {
node.position
} else {
null
}
}
.build()
}
}
@@ -99,7 +99,14 @@ class AiFunctionProviderImplTest {
val testNode =
Node(
num = 0xabc,
user = User(id = "!00000abc", long_name = "Alice", short_name = "AL"),
user =
User.Builder()
.also { wb ->
wb.id = "!00000abc"
wb.long_name = "Alice"
wb.short_name = "AL"
}
.build(),
lastHeard = 1_700_000_000,
snr = 5.5f,
rssi = -70,
@@ -122,7 +129,18 @@ class AiFunctionProviderImplTest {
@Test
fun getNodeDetails_returns_null_position_when_no_fix() = runTest {
// Node with (0.0, 0.0) position and time=0 → no valid position
val testNode = Node(num = 1, user = User(id = "!00000001", long_name = "NoGPS", short_name = "NG"))
val testNode =
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "NoGPS"
wb.short_name = "NG"
}
.build(),
)
val nodeMap = MutableStateFlow(mapOf(1 to testNode))
every { nodeRepository.nodeDBbyNum } returns nodeMap
@@ -151,7 +169,18 @@ class AiFunctionProviderImplTest {
fun getNodeDetails_round_trips_high_bit_node_num() = runTest {
// A node num with the high bit set (-1 == 0xFFFFFFFF) must format and parse as the canonical
// "!ffffffff", not the signed "!-1" — regression guard for the node-ID hex fix.
val testNode = Node(num = -1, user = User(id = "!ffffffff", long_name = "HighBit", short_name = "HB"))
val testNode =
Node(
num = -1,
user =
User.Builder()
.also { wb ->
wb.id = "!ffffffff"
wb.long_name = "HighBit"
wb.short_name = "HB"
}
.build(),
)
val nodeMap = MutableStateFlow(mapOf(-1 to testNode))
every { nodeRepository.nodeDBbyNum } returns nodeMap
@@ -271,7 +300,7 @@ class AiFunctionProviderImplTest {
@Test
fun sendMessage_accepts_text_exactly_at_the_byte_limit() = runTest {
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet.Builder().build())
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 42
val text = "a".repeat(AiFunctionProviderImpl.MAX_MESSAGE_LENGTH)
@@ -300,7 +329,7 @@ class AiFunctionProviderImplTest {
@Test
fun sendMessage_counts_multi_byte_text_in_bytes_not_characters() = runTest {
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet.Builder().build())
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } returns 7
// "\u00fc" is two UTF-8 bytes, so half as many characters fit.
@@ -320,12 +349,14 @@ class AiFunctionProviderImplTest {
// Data proto's framing fails here instead of in the send queue.
fun encodesWithinLimit(byteCount: Int): Boolean {
val data =
Data(
portnum = PortNum.TEXT_MESSAGE_APP,
payload = ByteArray(byteCount) { 'a'.code.toByte() }.toByteString(),
reply_id = 0,
emoji = 0,
)
Data.Builder()
.also { wb ->
wb.portnum = PortNum.TEXT_MESSAGE_APP
wb.payload = ByteArray(byteCount) { 'a'.code.toByte() }.toByteString()
wb.reply_id = 0
wb.emoji = 0
}
.build()
return Data.ADAPTER.isWithinSizeLimit(data, Constants.DATA_PAYLOAD_LEN.value)
}
@@ -339,7 +370,7 @@ class AiFunctionProviderImplTest {
fun getRecentMessages_contact_not_found() = runTest {
val nodeMap = MutableStateFlow(emptyMap<Int, Node>())
every { nodeRepository.nodeDBbyNum } returns nodeMap
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet.Builder().build())
val provider = createProvider()
val result = provider.getRecentMessages("NonExistent", 10)
@@ -352,7 +383,7 @@ class AiFunctionProviderImplTest {
fun getUnreadSummary_returns_empty_when_no_unread() = runTest {
every { packetRepository.getContacts() } returns flowOf(emptyMap())
every { packetRepository.getContactSettings() } returns flowOf(emptyMap())
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet())
every { radioConfigRepository.channelSetFlow } returns flowOf(org.meshtastic.proto.ChannelSet.Builder().build())
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(emptyMap())
val provider = createProvider()
@@ -101,8 +101,30 @@ class FuzzyNameResolverTest {
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val nodes =
mapOf(
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")),
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
1 to
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "Alice"
wb.short_name = "AL"
}
.build(),
),
2 to
Node(
num = 2,
user =
User.Builder()
.also { wb ->
wb.id = "!00000002"
wb.long_name = "Bob"
wb.short_name = "BO"
}
.build(),
),
)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
@@ -120,8 +142,30 @@ class FuzzyNameResolverTest {
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val nodes =
mapOf(
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alexander", short_name = "AX")),
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Bob", short_name = "BO")),
1 to
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "Alexander"
wb.short_name = "AX"
}
.build(),
),
2 to
Node(
num = 2,
user =
User.Builder()
.also { wb ->
wb.id = "!00000002"
wb.long_name = "Bob"
wb.short_name = "BO"
}
.build(),
),
)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
@@ -138,8 +182,30 @@ class FuzzyNameResolverTest {
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val nodes =
mapOf(
1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice Smith", short_name = "AS")),
2 to Node(num = 2, user = User(id = "!00000002", long_name = "Alice Jones", short_name = "AJ")),
1 to
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "Alice Smith"
wb.short_name = "AS"
}
.build(),
),
2 to
Node(
num = 2,
user =
User.Builder()
.also { wb ->
wb.id = "!00000002"
wb.long_name = "Alice Jones"
wb.short_name = "AJ"
}
.build(),
),
)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
@@ -167,7 +233,21 @@ class FuzzyNameResolverTest {
fun resolveNodeName_not_found_when_no_match() {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val nodes = mapOf(1 to Node(num = 1, user = User(id = "!00000001", long_name = "Alice", short_name = "AL")))
val nodes =
mapOf(
1 to
Node(
num = 1,
user =
User.Builder()
.also { wb ->
wb.id = "!00000001"
wb.long_name = "Alice"
wb.short_name = "AL"
}
.build(),
),
)
every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(nodes)
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
@@ -183,7 +263,15 @@ class FuzzyNameResolverTest {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val channelSet =
ChannelSet(settings = listOf(ChannelSettings(name = "General"), ChannelSettings(name = "Emergency")))
ChannelSet.Builder()
.also { wb ->
wb.settings =
listOf(
ChannelSettings.Builder().also { wb -> wb.name = "General" }.build(),
ChannelSettings.Builder().also { wb -> wb.name = "Emergency" }.build(),
)
}
.build()
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
@@ -199,7 +287,15 @@ class FuzzyNameResolverTest {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val channelSet =
ChannelSet(settings = listOf(ChannelSettings(name = "admin"), ChannelSettings(name = "General")))
ChannelSet.Builder()
.also { wb ->
wb.settings =
listOf(
ChannelSettings.Builder().also { wb -> wb.name = "admin" }.build(),
ChannelSettings.Builder().also { wb -> wb.name = "General" }.build(),
)
}
.build()
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
@@ -213,7 +309,7 @@ class FuzzyNameResolverTest {
fun resolveChannelName_not_found_when_empty() = runTest {
val nodeRepository: NodeRepository = mock(MockMode.autofill)
val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
val channelSet = ChannelSet(settings = emptyList())
val channelSet = ChannelSet.Builder().also { wb -> wb.settings = emptyList() }.build()
every { radioConfigRepository.channelSetFlow } returns flowOf(channelSet)
val resolver = FuzzyNameResolver(nodeRepository, radioConfigRepository)
@@ -105,8 +105,17 @@ class NodeDeletionDatabaseSwitchTest {
}
private suspend fun seedNodeAndMetadata(database: MeshtasticDatabase, label: String) {
database.nodeInfoDao().upsert(NodeEntity(num = NODE_NUM, user = User(id = "!node-$label")))
database.nodeInfoDao().upsert(MetadataEntity(num = NODE_NUM, proto = DeviceMetadata(firmware_version = label)))
database
.nodeInfoDao()
.upsert(NodeEntity(num = NODE_NUM, user = User.Builder().also { wb -> wb.id = "!node-$label" }.build()))
database
.nodeInfoDao()
.upsert(
MetadataEntity(
num = NODE_NUM,
proto = DeviceMetadata.Builder().also { wb -> wb.firmware_version = label }.build(),
),
)
}
private suspend fun MeshtasticDatabase.hasMetadata(num: Int): Boolean =
@@ -23,12 +23,17 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.testing.FakeDatabaseProvider
import org.meshtastic.proto.Channel
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
import org.meshtastic.proto.Config
import org.meshtastic.proto.PortNum
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
@@ -43,8 +48,13 @@ class SwitchingChannelSetDataSourceTest {
@AfterTest fun tearDown() = dbProvider.close()
private fun secondary(index: Int, name: String) =
Channel(index = index, settings = ChannelSettings(name = name), role = Channel.Role.SECONDARY)
private fun secondary(index: Int, name: String) = Channel.Builder()
.also { wb ->
wb.index = index
wb.settings = ChannelSettings.Builder().also { wb -> wb.name = name }.build()
wb.role = Channel.Role.SECONDARY
}
.build()
@Test
fun `updateChannelSettings places channel at its index`() = runTest(testDispatcher) {
@@ -69,16 +79,28 @@ class SwitchingChannelSetDataSourceTest {
@Test
fun `disabled channels are ignored`() = runTest(testDispatcher) {
dataSource.updateChannelSettings(Channel(index = 0, role = Channel.Role.DISABLED))
dataSource.updateChannelSettings(
Channel.Builder()
.also { wb ->
wb.index = 0
wb.role = Channel.Role.DISABLED
}
.build(),
)
assertTrue(dataSource.channelSetFlow.first().settings.isEmpty())
}
@Test
fun `replaceAllSettings replaces the whole list`() = runTest(testDispatcher) {
dataSource.updateChannelSettings(secondary(0, "old"))
val originalLora = Config.LoRaConfig(channel_num = 4)
val originalLora = Config.LoRaConfig.Builder().also { wb -> wb.channel_num = 4 }.build()
dataSource.setLoraConfig(originalLora)
dataSource.replaceAllSettings(listOf(ChannelSettings(name = "a"), ChannelSettings(name = "b")))
dataSource.replaceAllSettings(
listOf(
ChannelSettings.Builder().also { wb -> wb.name = "a" }.build(),
ChannelSettings.Builder().also { wb -> wb.name = "b" }.build(),
),
)
val set = dataSource.channelSetFlow.first()
assertEquals(listOf("a", "b"), set.settings.map { it.name })
@@ -87,18 +109,26 @@ class SwitchingChannelSetDataSourceTest {
@Test
fun `updateChannelSet atomically replaces settings and lora`() = runTest(testDispatcher) {
val settings = listOf(ChannelSettings(name = "new"))
val lora = Config.LoRaConfig(channel_num = 7)
val settings = listOf(ChannelSettings.Builder().also { wb -> wb.name = "new" }.build())
val lora = Config.LoRaConfig.Builder().also { wb -> wb.channel_num = 7 }.build()
dataSource.updateChannelSet(settingsList = settings, loraConfig = lora)
assertEquals(ChannelSet(settings = settings, lora_config = lora), dataSource.channelSetFlow.first())
assertEquals(
ChannelSet.Builder()
.also { wb ->
wb.settings = settings
wb.lora_config = lora
}
.build(),
dataSource.channelSetFlow.first(),
)
}
@Test
fun `setLoraConfig preserves existing channel settings`() = runTest(testDispatcher) {
dataSource.updateChannelSettings(secondary(0, "keep"))
dataSource.setLoraConfig(Config.LoRaConfig(channel_num = 7))
dataSource.setLoraConfig(Config.LoRaConfig.Builder().also { wb -> wb.channel_num = 7 }.build())
val set = dataSource.channelSetFlow.first()
assertEquals("keep", set.settings.single().name)
@@ -130,6 +160,115 @@ class SwitchingChannelSetDataSourceTest {
)
}
/**
* The first reconcile has no baseline to diff against, so it captures one and moves nothing. Without this an
* upgrade would try to reconcile every stored conversation against a set it had never seen.
*/
@Test
fun `the first reconcile only captures a baseline`() = runTest(testDispatcher) {
dataSource.updateChannelSettings(secondary(0, "primary"))
insertBroadcastPacket(channel = 0)
dataSource.reconcileConversations()
assertEquals(1, packetsOn(ContactKey.broadcast(0).value), "nothing to compare against, so nothing moves")
assertEquals(
"primary",
currentBaseline()?.settings?.single()?.name,
"the current set becomes the baseline for the next change",
)
}
/**
* The handshake streams channels one slot at a time, so reconciling on each of those writes would see a channel
* momentarily missing and retire a conversation that never went anywhere.
*/
@Test
fun `per-slot writes do not reconcile`() = runTest(testDispatcher) {
dataSource.updateChannelSet(
listOf(ChannelSettings.Builder().also { wb -> wb.name = "A" }.build()),
Config.LoRaConfig.Builder().also { wb -> wb.use_preset = true }.build(),
)
insertBroadcastPacket(channel = 0)
dataSource.updateChannelSettings(secondary(0, "B"))
assertEquals(1, packetsOn(ContactKey.broadcast(0).value), "a mid-handshake slot write must not re-key")
}
/** A whole-set write is the app's own change, so it is safe — and necessary — to re-key immediately. */
@Test
fun `a whole-set write reconciles immediately`() = runTest(testDispatcher) {
val lora = Config.LoRaConfig.Builder().also { wb -> wb.use_preset = true }.build()
dataSource.updateChannelSet(listOf(ChannelSettings.Builder().also { wb -> wb.name = "A" }.build()), lora)
insertBroadcastPacket(channel = 0)
dataSource.updateChannelSet(listOf(ChannelSettings.Builder().also { wb -> wb.name = "B" }.build()), lora)
assertEquals(0, packetsOn(ContactKey.broadcast(0).value), "the slot has a new occupant")
assertEquals(1, dbProvider.currentDb.value.packetDao().getRetiredPacketContactKeys().size)
}
/**
* Replays the handshake's real order: the cached channel set is dropped, then the radio's LoRa config arrives as
* its own write, and only after that do the channels stream in slot by slot.
*
* The LoRa write must not reconcile. It lands while the settings list is still empty, so reconciling there would
* see every channel as deleted and archive every conversation on the device — on every single reconnect.
*/
@Test
fun `the handshake lora write does not archive conversations`() = runTest(testDispatcher) {
val lora = Config.LoRaConfig.Builder().also { wb -> wb.use_preset = true }.build()
dataSource.updateChannelSet(listOf(ChannelSettings.Builder().also { wb -> wb.name = "A" }.build()), lora)
insertBroadcastPacket(channel = 0)
dataSource.clearChannelSet()
dataSource.setLoraConfig(lora)
assertEquals(1, packetsOn(ContactKey.broadcast(0).value), "a mid-handshake lora write must not re-key")
assertEquals(
0,
dbProvider.currentDb.value.packetDao().getRetiredPacketContactKeys().size,
"nothing may be archived while the channel list is still empty",
)
}
/** The messages did not move because the handshake dropped the cached channel set, so the baseline must survive. */
@Test
fun `clearing the channel set keeps the reconciliation baseline`() = runTest(testDispatcher) {
dataSource.updateChannelSet(
listOf(ChannelSettings.Builder().also { wb -> wb.name = "A" }.build()),
Config.LoRaConfig.Builder().also { wb -> wb.use_preset = true }.build(),
)
dataSource.clearChannelSet()
assertTrue(dataSource.channelSetFlow.first().settings.isEmpty())
assertEquals("A", currentBaseline()?.settings?.single()?.name)
}
private suspend fun currentBaseline(): ChannelSet? =
dbProvider.currentDb.value.channelSetDao().get()?.lastReconciledChannelSet
private suspend fun packetsOn(contactKey: String): Int =
dbProvider.currentDb.value.packetDao().getPacketsForContact(contactKey).size
private suspend fun insertBroadcastPacket(channel: Int) {
dbProvider.currentDb.value
.packetDao()
.insert(
Packet(
uuid = 0L,
myNodeNum = 1,
port_num = PortNum.TEXT_MESSAGE_APP.value,
contact_key = ContactKey.broadcast(channel).value,
received_time = 1L,
read = false,
data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = channel, text = "hi"),
),
)
}
/** Handshake fires overlapping updateChannelSettings as it streams channels; the mutex must not drop any. */
@Test
fun `concurrent updateChannelSettings does not lose updates`() = runTest(testDispatcher) {
@@ -64,7 +64,18 @@ class AdminPacketHandlerImplTest {
private fun makePacket(from: Int, adminMessage: AdminMessage): MeshPacket {
val payload = adminMessage.encode().toByteString()
return MeshPacket(from = from, decoded = Data(portnum = PortNum.ADMIN_APP, payload = payload))
return MeshPacket.Builder()
.also { wb ->
wb.from = from
wb.decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.ADMIN_APP
wb.payload = payload
}
.build()
}
.build()
}
private fun handle(packet: MeshPacket) = handler.handleAdminMessage(packet, myNodeNum, session)
@@ -74,7 +85,7 @@ class AdminPacketHandlerImplTest {
@Test
fun `session passkey is updated when present`() {
val passkey = ByteString.of(1, 2, 3, 4)
val adminMsg = AdminMessage(session_passkey = passkey)
val adminMsg = AdminMessage.Builder().also { wb -> wb.session_passkey = passkey }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -84,7 +95,7 @@ class AdminPacketHandlerImplTest {
@Test
fun `empty session passkey does not record refresh`() {
val adminMsg = AdminMessage(session_passkey = ByteString.EMPTY)
val adminMsg = AdminMessage.Builder().also { wb -> wb.session_passkey = ByteString.EMPTY }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -95,8 +106,14 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_config_response from own node delegates to configHandler`() {
val config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT))
val adminMsg = AdminMessage(get_config_response = config)
val config =
Config.Builder()
.also { wb ->
wb.device =
Config.DeviceConfig.Builder().also { wb -> wb.role = Config.DeviceConfig.Role.CLIENT }.build()
}
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_config_response = config }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -106,8 +123,8 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_config_response from remote node is ignored`() {
val config = Config(device = Config.DeviceConfig())
val adminMsg = AdminMessage(get_config_response = config)
val config = Config.Builder().also { wb -> wb.device = Config.DeviceConfig.Builder().build() }.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_config_response = config }.build()
val packet = makePacket(99999, adminMsg)
handle(packet)
@@ -118,8 +135,11 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_module_config_response from own node delegates to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val moduleConfig =
ModuleConfig.Builder()
.also { wb -> wb.mqtt = ModuleConfig.MQTTConfig.Builder().also { wb -> wb.enabled = true }.build() }
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_module_config_response = moduleConfig }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -129,8 +149,14 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_module_config_response from remote node updates node status`() {
val moduleConfig = ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig(node_status = "Battery Low"))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val moduleConfig =
ModuleConfig.Builder()
.also { wb ->
wb.statusmessage =
ModuleConfig.StatusMessageConfig.Builder().also { wb -> wb.node_status = "Battery Low" }.build()
}
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_module_config_response = moduleConfig }.build()
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
@@ -141,8 +167,11 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_module_config_response from remote without status message does not crash`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val adminMsg = AdminMessage(get_module_config_response = moduleConfig)
val moduleConfig =
ModuleConfig.Builder()
.also { wb -> wb.mqtt = ModuleConfig.MQTTConfig.Builder().also { wb -> wb.enabled = true }.build() }
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_module_config_response = moduleConfig }.build()
val packet = makePacket(99999, adminMsg)
handle(packet)
@@ -153,8 +182,8 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_channel_response from own node delegates to configHandler`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val channel = Channel.Builder().also { wb -> wb.index = 0 }.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_channel_response = channel }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -164,8 +193,8 @@ class AdminPacketHandlerImplTest {
@Test
fun `get_channel_response from remote node is ignored`() {
val channel = Channel(index = 0)
val adminMsg = AdminMessage(get_channel_response = channel)
val channel = Channel.Builder().also { wb -> wb.index = 0 }.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_channel_response = channel }.build()
val packet = makePacket(99999, adminMsg)
handle(packet)
@@ -176,8 +205,14 @@ class AdminPacketHandlerImplTest {
@Test
fun `device metadata from own node delegates to configFlowManager`() {
val metadata = DeviceMetadata(firmware_version = "2.6.0", hw_model = HardwareModel.HELTEC_V3)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val metadata =
DeviceMetadata.Builder()
.also { wb ->
wb.firmware_version = "2.6.0"
wb.hw_model = HardwareModel.HELTEC_V3
}
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_device_metadata_response = metadata }.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -187,8 +222,14 @@ class AdminPacketHandlerImplTest {
@Test
fun `device metadata from remote node delegates to nodeManager`() {
val metadata = DeviceMetadata(firmware_version = "2.5.0", hw_model = HardwareModel.TBEAM)
val adminMsg = AdminMessage(get_device_metadata_response = metadata)
val metadata =
DeviceMetadata.Builder()
.also { wb ->
wb.firmware_version = "2.5.0"
wb.hw_model = HardwareModel.TBEAM
}
.build()
val adminMsg = AdminMessage.Builder().also { wb -> wb.get_device_metadata_response = metadata }.build()
val remoteNode = 99999
val packet = makePacket(remoteNode, adminMsg)
@@ -201,7 +242,13 @@ class AdminPacketHandlerImplTest {
@Test
fun `packet with null decoded payload is ignored`() {
val packet = MeshPacket(from = myNodeNum, decoded = null)
val packet =
MeshPacket.Builder()
.also { wb ->
wb.from = myNodeNum
wb.decoded = null
}
.build()
handle(packet)
// No crash
}
@@ -209,7 +256,18 @@ class AdminPacketHandlerImplTest {
@Test
fun `packet with empty payload bytes is ignored`() {
val packet =
MeshPacket(from = myNodeNum, decoded = Data(portnum = PortNum.ADMIN_APP, payload = ByteString.EMPTY))
MeshPacket.Builder()
.also { wb ->
wb.from = myNodeNum
wb.decoded =
Data.Builder()
.also { wb ->
wb.portnum = PortNum.ADMIN_APP
wb.payload = ByteString.EMPTY
}
.build()
}
.build()
handle(packet)
// No crash — decodes as default AdminMessage with no fields set
}
@@ -217,8 +275,14 @@ class AdminPacketHandlerImplTest {
@Test
fun `combined admin message with passkey and config response`() {
val passkey = ByteString.of(5, 6, 7, 8)
val config = Config(lora = Config.LoRaConfig())
val adminMsg = AdminMessage(session_passkey = passkey, get_config_response = config)
val config = Config.Builder().also { wb -> wb.lora = Config.LoRaConfig.Builder().build() }.build()
val adminMsg =
AdminMessage.Builder()
.also { wb ->
wb.session_passkey = passkey
wb.get_config_response = config
}
.build()
val packet = makePacket(myNodeNum, adminMsg)
handle(packet)
@@ -78,8 +78,8 @@ class CommandSenderImplTest {
@BeforeTest
fun setup() {
every { radioConfigRepository.localConfigFlow } returns flowOf(LocalConfig())
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet())
every { radioConfigRepository.localConfigFlow } returns flowOf(LocalConfig.Builder().build())
every { radioConfigRepository.channelSetFlow } returns flowOf(ChannelSet.Builder().build())
every { nodeManager.myNodeNum } returns MutableStateFlow(MY_NODE_NUM)
every { nodeManager.nodeDBbyNodeNum } returns emptyMap()
every { sessionManager.getPasskey(any()) } returns ByteString.EMPTY
@@ -153,7 +153,7 @@ class CommandSenderImplTest {
@Test
fun resolveNodeNum_byId_looksUpAndReturns() {
val node = Node(num = 99, user = User(id = "!deadbeef"))
val node = Node(num = 99, user = User.Builder().also { wb -> wb.id = "!deadbeef" }.build())
every { nodeManager.getNodeById("!deadbeef") } returns node
assertEquals(99, commandSender.resolveNodeNum(NodeAddress.ById("!deadbeef")))
}
@@ -224,7 +224,7 @@ class CommandSenderImplTest {
val packets = mutableListOf<MeshPacket>()
everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
commandSender.sendAdmin(DEST_NODE) { AdminMessage(get_owner_request = true) }
commandSender.sendAdmin(DEST_NODE) { AdminMessage.Builder().also { wb -> wb.get_owner_request = true }.build() }
val adminMessage = AdminMessage.ADAPTER.decode(requireNotNull(packets.single().decoded).payload)
assertEquals(passkey, adminMessage.session_passkey)
@@ -235,7 +235,9 @@ class CommandSenderImplTest {
val packets = mutableListOf<MeshPacket>()
everySuspend { packetHandler.sendToRadio(capture(packets)) } returns true
commandSender.sendAdmin(DEST_NODE, requestId = 0) { AdminMessage(get_owner_request = true) }
commandSender.sendAdmin(DEST_NODE, requestId = 0) {
AdminMessage.Builder().also { wb -> wb.get_owner_request = true }.build()
}
assertNotEquals(0, packets.single().id)
}
@@ -244,7 +246,9 @@ class CommandSenderImplTest {
fun sendAdminSurfacesQueueRejection() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
assertFailsWith<PacketQueueRejectedException> { commandSender.sendAdmin(DEST_NODE) { AdminMessage() } }
assertFailsWith<PacketQueueRejectedException> {
commandSender.sendAdmin(DEST_NODE) { AdminMessage.Builder().build() }
}
}
@Test
@@ -252,7 +256,9 @@ class CommandSenderImplTest {
val packets = mutableListOf<MeshPacket>()
everySuspend { packetHandler.sendToRadioForConnection(capture(packets), 17L) } returns true
commandSender.sendAdminForConnection(DEST_NODE, expectedConnectionVersion = 17L) { AdminMessage() }
commandSender.sendAdminForConnection(DEST_NODE, expectedConnectionVersion = 17L) {
AdminMessage.Builder().build()
}
assertEquals(1, packets.size)
verifySuspend { packetHandler.sendToRadioForConnection(any<MeshPacket>(), 17L) }
@@ -264,7 +270,9 @@ class CommandSenderImplTest {
everySuspend { packetHandler.sendToRadioAndAwaitResult(capture(packets)) } returns
AwaitedSendResult(AwaitedSendStatus.ACCEPTED, departureEpochAtDispatch = 0)
commandSender.sendAdminAwaitResult(DEST_NODE, requestId = 0) { AdminMessage(get_owner_request = true) }
commandSender.sendAdminAwaitResult(DEST_NODE, requestId = 0) {
AdminMessage.Builder().also { wb -> wb.get_owner_request = true }.build()
}
assertNotEquals(0, packets.single().id)
}
@@ -274,7 +282,10 @@ class CommandSenderImplTest {
val expected = AwaitedSendResult(AwaitedSendStatus.REJECTED)
everySuspend { packetHandler.sendToRadioAndAwaitResult(any<MeshPacket>()) } returns expected
val result = commandSender.sendAdminAwaitResult(DEST_NODE) { AdminMessage(get_owner_request = true) }
val result =
commandSender.sendAdminAwaitResult(DEST_NODE) {
AdminMessage.Builder().also { wb -> wb.get_owner_request = true }.build()
}
assertEquals(expected, result)
assertFalse(result.accepted)
@@ -317,7 +328,9 @@ class CommandSenderImplTest {
every { sessionManager.getPasskey(DEST_NODE) } returns passkey
every { packetHandler.sendToRadio(any<ToRadio>()) } returns Unit
commandSender.sendAdminImmediate(DEST_NODE) { AdminMessage(set_time_only = 12345) }
commandSender.sendAdminImmediate(DEST_NODE) {
AdminMessage.Builder().also { wb -> wb.set_time_only = 12345 }.build()
}
// Direct ToRadio dispatch (not the Connected-gated MeshPacket queue), correct destination,
// no want_response, and the session passkey injected into the admin payload.
@@ -410,7 +423,13 @@ class CommandSenderImplTest {
@Test
fun requestNeighborInfo_localNode_usesCachedNeighborInfo() = runTest {
val cached = NeighborInfo(node_id = MY_NODE_NUM, last_sent_by_id = MY_NODE_NUM)
val cached =
NeighborInfo.Builder()
.also { wb ->
wb.node_id = MY_NODE_NUM
wb.last_sent_by_id = MY_NODE_NUM
}
.build()
every { neighborInfoHandler.lastNeighborInfo } returns cached
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
@@ -470,7 +489,13 @@ class CommandSenderImplTest {
fun sendPosition_updatesLocalPositionWhenNotFixed() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
val pos =
org.meshtastic.proto.Position.Builder()
.also { wb ->
wb.latitude_i = 10000000
wb.longitude_i = 20000000
}
.build()
commandSender.sendPosition(pos)
verify { nodeManager.handleReceivedPosition(MY_NODE_NUM, MY_NODE_NUM, any(), any()) }
@@ -479,7 +504,13 @@ class CommandSenderImplTest {
@Test
fun sendPosition_doesNotUpdateLocalPositionWhenQueueRejectsPacket() = runTest {
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns false
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
val pos =
org.meshtastic.proto.Position.Builder()
.also { wb ->
wb.latitude_i = 10000000
wb.longitude_i = 20000000
}
.build()
assertFailsWith<PacketQueueRejectedException> { commandSender.sendPosition(pos) }
@@ -490,7 +521,9 @@ class CommandSenderImplTest {
fun sendPosition_rejectsWhenLocalNodeIdentityIsUnavailable() = runTest {
every { nodeManager.myNodeNum } returns MutableStateFlow(null)
assertFailsWith<LocalNodeUnavailableException> { commandSender.sendPosition(org.meshtastic.proto.Position()) }
assertFailsWith<LocalNodeUnavailableException> {
commandSender.sendPosition(org.meshtastic.proto.Position.Builder().build())
}
verifySuspend(exactly(0)) { packetHandler.sendToRadio(any<MeshPacket>()) }
}
@@ -544,9 +577,18 @@ class CommandSenderImplTest {
fun sendPosition_skipsLocalUpdateWhenFixedPosition() = runTest {
// Use MutableStateFlow so the init launchIn picks it up immediately in TestScope
val configFlow =
MutableStateFlow(LocalConfig(position = org.meshtastic.proto.Config.PositionConfig(fixed_position = true)))
MutableStateFlow(
LocalConfig.Builder()
.also { wb ->
wb.position =
org.meshtastic.proto.Config.PositionConfig.Builder()
.also { wb -> wb.fixed_position = true }
.build()
}
.build(),
)
every { radioConfigRepository.localConfigFlow } returns configFlow
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet())
every { radioConfigRepository.channelSetFlow } returns MutableStateFlow(ChannelSet.Builder().build())
val testScope = TestScope()
val fixedSender =
CommandSenderImpl(
@@ -561,7 +603,13 @@ class CommandSenderImplTest {
testScope.testScheduler.advanceUntilIdle()
everySuspend { packetHandler.sendToRadio(any<MeshPacket>()) } returns true
val pos = org.meshtastic.proto.Position(latitude_i = 10000000, longitude_i = 20000000)
val pos =
org.meshtastic.proto.Position.Builder()
.also { wb ->
wb.latitude_i = 10000000
wb.longitude_i = 20000000
}
.build()
fixedSender.sendPosition(pos)
verify(mode = dev.mokkery.verify.VerifyMode.not) {
@@ -123,8 +123,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes MY_INFO to configFlowManager`() {
val myInfo = MyNodeInfo(my_node_num = 1234)
val proto = FromRadio(my_info = myInfo)
val myInfo = MyNodeInfo.Builder().also { wb -> wb.my_node_num = 1234 }.build()
val proto = FromRadio.Builder().also { wb -> wb.my_info = myInfo }.build()
handle(proto)
@@ -133,8 +133,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes METADATA to configFlowManager`() {
val metadata = DeviceMetadata(firmware_version = "v1.0")
val proto = FromRadio(metadata = metadata)
val metadata = DeviceMetadata.Builder().also { wb -> wb.firmware_version = "v1.0" }.build()
val proto = FromRadio.Builder().also { wb -> wb.metadata = metadata }.build()
handle(proto)
@@ -143,8 +143,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes NODE_INFO to configFlowManager and updates status`() {
val nodeInfo = ProtoNodeInfo(num = 1234)
val proto = FromRadio(node_info = nodeInfo)
val nodeInfo = ProtoNodeInfo.Builder().also { wb -> wb.num = 1234 }.build()
val proto = FromRadio.Builder().also { wb -> wb.node_info = nodeInfo }.build()
every { configFlowManager.newNodeCount } returns 1
@@ -157,7 +157,7 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes CONFIG_COMPLETE_ID to configFlowManager`() {
val nonce = 69420
val proto = FromRadio(config_complete_id = nonce)
val proto = FromRadio.Builder().also { wb -> wb.config_complete_id = nonce }.build()
handle(proto)
@@ -167,10 +167,10 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `stale NODE_INFO does not update connection progress`() {
val nodeInfo = ProtoNodeInfo(num = 1234)
val nodeInfo = ProtoNodeInfo.Builder().also { wb -> wb.num = 1234 }.build()
every { configFlowManager.handleNodeInfo(nodeInfo, session) } returns false
handle(FromRadio(node_info = nodeInfo))
handle(FromRadio.Builder().also { wb -> wb.node_info = nodeInfo }.build())
verify { configFlowManager.handleNodeInfo(nodeInfo, session) }
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionProgress(any()) }
@@ -181,7 +181,7 @@ class FromRadioPacketHandlerImplTest {
val nonce = 69420
every { configFlowManager.handleConfigComplete(nonce, session) } returns false
handle(FromRadio(config_complete_id = nonce))
handle(FromRadio.Builder().also { wb -> wb.config_complete_id = nonce }.build())
verify { configFlowManager.handleConfigComplete(nonce, session) }
assertFalse(lockdownCoordinator.configCompleteCalled)
@@ -189,7 +189,7 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `revoked session skips node progress after handler returns`() {
val nodeInfo = ProtoNodeInfo(num = 1234)
val nodeInfo = ProtoNodeInfo.Builder().also { wb -> wb.num = 1234 }.build()
var active = true
every { configFlowManager.handleNodeInfo(nodeInfo, session) } calls
{
@@ -208,7 +208,7 @@ class FromRadioPacketHandlerImplTest {
}
}
handle(FromRadio(node_info = nodeInfo))
handle(FromRadio.Builder().also { wb -> wb.node_info = nodeInfo }.build())
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setConnectionProgress(any()) }
}
@@ -216,17 +216,26 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `revoked session skips direct packet-dispatch branches`() {
every { radioInterfaceService.runIfSessionActive(session, any()) } returns false
val proxyMessage = MqttClientProxyMessage(topic = "test/topic")
val queueStatus = QueueStatus(free = 10)
val xmodemPacket = XModem()
val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED)
val proxyMessage = MqttClientProxyMessage.Builder().also { wb -> wb.topic = "test/topic" }.build()
val queueStatus = QueueStatus.Builder().also { wb -> wb.free = 10 }.build()
val xmodemPacket = XModem.Builder().build()
val lockdownStatus = LockdownStatus.Builder().also { wb -> wb.state = LockdownStatus.State.LOCKED }.build()
handle(FromRadio(mqttClientProxyMessage = proxyMessage))
handle(FromRadio(queueStatus = queueStatus))
handle(FromRadio(xmodemPacket = xmodemPacket))
handle(FromRadio(lockdown_status = lockdownStatus))
handle(FromRadio(display_frame = DisplayFrame(width = 128, height = 64)))
handle(FromRadio(display_palette = DisplayPalette(signature = 1)))
val displayFrame =
DisplayFrame.Builder()
.also { wb ->
wb.width = 128
wb.height = 64
}
.build()
val displayPalette = DisplayPalette.Builder().also { wb -> wb.signature = 1 }.build()
handle(FromRadio.Builder().also { wb -> wb.mqttClientProxyMessage = proxyMessage }.build())
handle(FromRadio.Builder().also { wb -> wb.queueStatus = queueStatus }.build())
handle(FromRadio.Builder().also { wb -> wb.xmodemPacket = xmodemPacket }.build())
handle(FromRadio.Builder().also { wb -> wb.lockdown_status = lockdownStatus }.build())
handle(FromRadio.Builder().also { wb -> wb.display_frame = displayFrame }.build())
handle(FromRadio.Builder().also { wb -> wb.display_palette = displayPalette }.build())
verify(mode = VerifyMode.exactly(0)) { mqttManager.handleMqttProxyMessage(any()) }
verify(mode = VerifyMode.exactly(0)) { packetHandler.handleQueueStatus(any()) }
@@ -238,26 +247,46 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes DISPLAY_FRAME to displayMirrorManager`() {
val frame = DisplayFrame(width = 128, height = 64, frame_id = 1, total_size = 1024)
val frame =
DisplayFrame.Builder()
.also { wb ->
wb.width = 128
wb.height = 64
wb.frame_id = 1
wb.total_size = 1024
}
.build()
handle(FromRadio(display_frame = frame))
handle(FromRadio.Builder().also { wb -> wb.display_frame = frame }.build())
verify { displayMirrorManager.handleIncomingFrame(frame) }
}
@Test
fun `handleFromRadio routes DISPLAY_PALETTE to displayMirrorManager`() {
val palette = DisplayPalette(signature = 7, region_total = 0)
val palette =
DisplayPalette.Builder()
.also { wb ->
wb.signature = 7
wb.region_total = 0
}
.build()
handle(FromRadio(display_palette = palette))
handle(FromRadio.Builder().also { wb -> wb.display_palette = palette }.build())
verify { displayMirrorManager.handleIncomingPalette(palette) }
}
@Test
fun `handleFromRadio routes LOCKDOWN_STATUS to lockdownCoordinator`() {
val lockdownStatus = LockdownStatus(state = LockdownStatus.State.LOCKED, lock_reason = "token_missing")
val proto = FromRadio(lockdown_status = lockdownStatus)
val lockdownStatus =
LockdownStatus.Builder()
.also { wb ->
wb.state = LockdownStatus.State.LOCKED
wb.lock_reason = "token_missing"
}
.build()
val proto = FromRadio.Builder().also { wb -> wb.lockdown_status = lockdownStatus }.build()
handle(proto)
@@ -266,8 +295,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes QUEUESTATUS to packetHandler`() {
val queueStatus = QueueStatus(free = 10)
val proto = FromRadio(queueStatus = queueStatus)
val queueStatus = QueueStatus.Builder().also { wb -> wb.free = 10 }.build()
val proto = FromRadio.Builder().also { wb -> wb.queueStatus = queueStatus }.build()
handle(proto)
@@ -276,8 +305,11 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes CONFIG to configHandler`() {
val config = Config(lora = Config.LoRaConfig(use_preset = true))
val proto = FromRadio(config = config)
val config =
Config.Builder()
.also { wb -> wb.lora = Config.LoRaConfig.Builder().also { wb -> wb.use_preset = true }.build() }
.build()
val proto = FromRadio.Builder().also { wb -> wb.config = config }.build()
handle(proto)
@@ -286,8 +318,11 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes MODULE_CONFIG to configHandler`() {
val moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true))
val proto = FromRadio(moduleConfig = moduleConfig)
val moduleConfig =
ModuleConfig.Builder()
.also { wb -> wb.mqtt = ModuleConfig.MQTTConfig.Builder().also { wb -> wb.enabled = true }.build() }
.build()
val proto = FromRadio.Builder().also { wb -> wb.moduleConfig = moduleConfig }.build()
handle(proto)
@@ -296,8 +331,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes CHANNEL to configHandler`() {
val channel = Channel(index = 0)
val proto = FromRadio(channel = channel)
val channel = Channel.Builder().also { wb -> wb.index = 0 }.build()
val proto = FromRadio.Builder().also { wb -> wb.channel = channel }.build()
handle(proto)
@@ -306,8 +341,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes REGION_PRESETS to configHandler`() {
val map = LoRaRegionPresetMap()
val proto = FromRadio(region_presets = map)
val map = LoRaRegionPresetMap.Builder().build()
val proto = FromRadio.Builder().also { wb -> wb.region_presets = map }.build()
handle(proto)
@@ -316,8 +351,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes MQTT_CLIENT_PROXY_MESSAGE to mqttManager`() {
val proxyMsg = MqttClientProxyMessage(topic = "test/topic")
val proto = FromRadio(mqttClientProxyMessage = proxyMsg)
val proxyMsg = MqttClientProxyMessage.Builder().also { wb -> wb.topic = "test/topic" }.build()
val proto = FromRadio.Builder().also { wb -> wb.mqttClientProxyMessage = proxyMsg }.build()
handle(proto)
@@ -326,8 +361,8 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `handleFromRadio routes CLIENTNOTIFICATION to serviceRepository`() {
val notification = ClientNotification(message = "test")
val proto = FromRadio(clientNotification = notification)
val notification = ClientNotification.Builder().also { wb -> wb.message = "test" }.build()
val proto = FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build()
// Note: getString() from Compose Resources requires Skiko native lib which
// is not available in headless JVM tests. We test the parts that don't trigger it.
@@ -345,7 +380,7 @@ class FromRadioPacketHandlerImplTest {
val notification = protectedPositionAdvisory(replyId = 100, time = 1_000)
every { notificationManager.suppressClientNotificationModal(notification) } returns true
handle(FromRadio(clientNotification = notification))
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setClientNotification(any()) }
verifySuspend(mode = VerifyMode.exactly(1)) { radioInterfaceService.runWithSessionLease(session, any()) }
@@ -356,7 +391,7 @@ class FromRadioPacketHandlerImplTest {
val notification = protectedPositionAdvisory(replyId = 200, time = 2_000)
every { notificationManager.suppressClientNotificationModal(notification) } returns false
handle(FromRadio(clientNotification = notification))
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify { serviceRepository.setClientNotification(notification) }
verifySuspend { radioInterfaceService.runWithSessionLease(session, any()) }
@@ -364,10 +399,10 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `stale client notification is discarded before publication`() {
val notification = ClientNotification(message = "stale")
val notification = ClientNotification.Builder().also { wb -> wb.message = "stale" }.build()
every { radioInterfaceService.runIfSessionActive(session, any()) } returns false
handle(FromRadio(clientNotification = notification))
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify(mode = VerifyMode.exactly(0)) { serviceRepository.setClientNotification(any()) }
verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
@@ -375,10 +410,10 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `client alert is skipped when the session retires after publication`() {
val notification = ClientNotification(message = "retired")
val notification = ClientNotification.Builder().also { wb -> wb.message = "retired" }.build()
everySuspend { radioInterfaceService.runWithSessionLease(session, any()) } returns false
handle(FromRadio(clientNotification = notification))
handle(FromRadio.Builder().also { wb -> wb.clientNotification = notification }.build())
verify { serviceRepository.setClientNotification(notification) }
verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatchClientNotification(any(), any()) }
@@ -386,26 +421,55 @@ class FromRadioPacketHandlerImplTest {
@Test
fun `OTA status client notifications are identified`() {
assertTrue(ClientNotification(message = "Rebooting to WiFi OTA").isOtaStatusNotification())
assertTrue(ClientNotification(message = "OTA Loader does not support WiFi").isOtaStatusNotification())
assertTrue(
ClientNotification(message = "Cannot start OTA: OTA Loader partition not found.").isOtaStatusNotification(),
ClientNotification.Builder()
.also { wb -> wb.message = "Rebooting to WiFi OTA" }
.build()
.isOtaStatusNotification(),
)
assertTrue(
ClientNotification.Builder()
.also { wb -> wb.message = "OTA Loader does not support WiFi" }
.build()
.isOtaStatusNotification(),
)
assertTrue(
ClientNotification.Builder()
.also { wb -> wb.message = "Cannot start OTA: OTA Loader partition not found." }
.build()
.isOtaStatusNotification(),
)
assertTrue(
ClientNotification.Builder()
.also { wb -> wb.message = "Unable to switch to the OTA partition." }
.build()
.isOtaStatusNotification(),
)
assertTrue(ClientNotification(message = "Unable to switch to the OTA partition.").isOtaStatusNotification())
}
@Test
fun `non OTA client notifications are not identified as OTA status`() {
assertFalse(ClientNotification(message = "test").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Low battery").isOtaStatusNotification())
assertFalse(ClientNotification(message = "ROTATE credentials").isOtaStatusNotification())
assertFalse(ClientNotification(message = "Quota exceeded").isOtaStatusNotification())
assertFalse(ClientNotification.Builder().also { wb -> wb.message = "test" }.build().isOtaStatusNotification())
assertFalse(
ClientNotification.Builder().also { wb -> wb.message = "Low battery" }.build().isOtaStatusNotification(),
)
assertFalse(
ClientNotification.Builder()
.also { wb -> wb.message = "ROTATE credentials" }
.build()
.isOtaStatusNotification(),
)
assertFalse(
ClientNotification.Builder().also { wb -> wb.message = "Quota exceeded" }.build().isOtaStatusNotification(),
)
}
private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification(
message = "Location sharing is disabled on this channel",
reply_id = replyId,
time = time,
level = LogRecord.Level.WARNING,
)
private fun protectedPositionAdvisory(replyId: Int, time: Int) = ClientNotification.Builder()
.also { wb ->
wb.message = "Location sharing is disabled on this channel"
wb.reply_id = replyId
wb.time = time
wb.level = LogRecord.Level.WARNING
}
.build()
}
@@ -62,19 +62,34 @@ class GeofenceMonitorTest {
// Geofence centred at (10, 20), 1 km radius.
private val centerLatI = 100_000_000
private val centerLonI = 200_000_000
private val inside = Position(latitude_i = centerLatI, longitude_i = centerLonI) // distance 0
private val outside = Position(latitude_i = 110_000_000, longitude_i = centerLonI) // ~111 km north
private val inside =
Position.Builder()
.also { wb ->
wb.latitude_i = centerLatI
wb.longitude_i = centerLonI
}
.build() // distance 0
private val outside =
Position.Builder()
.also { wb ->
wb.latitude_i = 110_000_000
wb.longitude_i = centerLonI
}
.build() // ~111 km north
private fun waypoint(enter: Boolean = true, exit: Boolean = false, favoritesOnly: Boolean = false) = Waypoint(
id = 42,
latitude_i = centerLatI,
longitude_i = centerLonI,
name = "Base",
geofence_radius = 1000,
notify_on_enter = enter,
notify_on_exit = exit,
notify_favorites_only = favoritesOnly,
)
private fun waypoint(enter: Boolean = true, exit: Boolean = false, favoritesOnly: Boolean = false) =
Waypoint.Builder()
.also { wb ->
wb.id = 42
wb.latitude_i = centerLatI
wb.longitude_i = centerLonI
wb.name = "Base"
wb.geofence_radius = 1000
wb.notify_on_enter = enter
wb.notify_on_exit = exit
wb.notify_favorites_only = favoritesOnly
}
.build()
private data class Mocks(
val packetRepository: PacketRepository,
@@ -102,7 +102,8 @@ class HistoryManagerImplTest {
.requestHistoryReplay(
trigger = "test",
myNodeNum = null,
storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
storeForwardConfig =
ModuleConfig.StoreForwardConfig.Builder().also { wb -> wb.enabled = true }.build(),
transport = "BLE",
expectedConnectionVersion = 17L,
)
@@ -121,7 +122,8 @@ class HistoryManagerImplTest {
.requestHistoryReplay(
trigger = "test",
myNodeNum = 123,
storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
storeForwardConfig =
ModuleConfig.StoreForwardConfig.Builder().also { wb -> wb.enabled = true }.build(),
transport = "BLE",
expectedConnectionVersion = 17L,
)
@@ -142,7 +144,7 @@ class HistoryManagerImplTest {
.requestHistoryReplay(
trigger = "test",
myNodeNum = 123,
storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
storeForwardConfig = ModuleConfig.StoreForwardConfig.Builder().also { wb -> wb.enabled = true }.build(),
transport = "BLE",
expectedConnectionVersion = 17L,
)
@@ -171,7 +173,8 @@ class HistoryManagerImplTest {
.requestHistoryReplay(
trigger = "test",
myNodeNum = 123,
storeForwardConfig = ModuleConfig.StoreForwardConfig(enabled = true),
storeForwardConfig =
ModuleConfig.StoreForwardConfig.Builder().also { wb -> wb.enabled = true }.build(),
transport = "BLE",
expectedConnectionVersion = 23L,
)
Loaded 100 of 1432 files, more files were not shown because too many files have changed in this diff. Show more