mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-10-08 03:21:23 -04:00
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:
commit
720c4c246f
1432 files changed
+42864
-15136
No files matched your search
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
4.0.6
|
||||
4.0.7
|
||||
Generated
+52
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
+24
-15
@@ -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")
|
||||
+47
@@ -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))
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
-54
@@ -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
|
||||
}
|
||||
}
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -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
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ interface NodeInfoReadDataSource {
|
||||
fun getNodesFlow(
|
||||
sort: String,
|
||||
includeUnknown: Boolean,
|
||||
hopsAwayMax: Int,
|
||||
onlyDirect: Boolean,
|
||||
lastHeardMin: Int,
|
||||
): Flow<List<NodeWithRelations>>
|
||||
|
||||
|
||||
+108
-11
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+172
-107
@@ -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 {
|
||||
|
||||
+5
-1
@@ -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" }
|
||||
|
||||
+28
-13
@@ -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")
|
||||
|
||||
+10
-1
@@ -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()
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+11
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -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(
|
||||
|
||||
+6
-3
@@ -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
|
||||
|
||||
+48
-25
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+11
-1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
+5
-1
@@ -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() {
|
||||
|
||||
+22
-17
@@ -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 ->
|
||||
|
||||
+34
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
+1
-7
@@ -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(
|
||||
|
||||
+21
-15
@@ -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()
|
||||
}
|
||||
}
|
||||
+44
-13
@@ -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()
|
||||
|
||||
+106
-10
@@ -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)
|
||||
|
||||
+11
-2
@@ -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 =
|
||||
|
||||
+148
-9
@@ -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) {
|
||||
|
||||
+89
-25
@@ -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)
|
||||
|
||||
+65
-17
@@ -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) {
|
||||
|
||||
+126
-62
@@ -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()
|
||||
}
|
||||
+27
-12
@@ -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,
|
||||
|
||||
+7
-4
@@ -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
Reference in new issue
Block a user