Merge remote-tracking branch 'origin/main' into features/lockdown-v2

# Conflicts:
#	.specify/feature.json
#	AGENTS.md
#	core/proto/src/main/proto
This commit is contained in:
niccellular
2026-05-21 14:01:47 -04:00
1447 changed files with 114241 additions and 6259 deletions

View File

@@ -3,6 +3,50 @@
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
# Format: ## YYYY-MM-DD — <summary>
## 2026-05-20 — Overhauled and Bulletproofed Flatpak Source Generation
- Overhauled and streamlined `build-logic/convention/build.gradle.kts` to dynamically query and resolve all 25+ Version Catalog plugin marker coordinates in a type-safe dynamic loop.
- Replaced the hardcoded embedded Gradle Kotlin compiler version with dynamic standard library detection: `KotlinVersion.CURRENT.toString()`.
- Implemented root Gradle consolidator task `:combineFlatpakSources` using JVM-native Json Slurper/Output, deduplicating 2222 entries and injecting Google/Aliyun backup `mirror-urls`.
- Streamlined reusable-check and release CI workflows, replacing complex `jq` hacks with a simple, single gradle task invocation.
- Verified `spotlessCheck`, `detekt`, and end-to-end completely offline package builds (`--offline`) completed with 100% success.
## 2026-05-20 — Refactored and polished Flatpak dependency manifests to modern Gradle standards
- Polished the Flatpak generator tasks and convention setup following a comprehensive audit:
1. Centralized version definitions to reference the central `libs.versions` catalog (`kotlin`, `koin.plugin`, etc.) instead of hardcoded strings.
2. Documented every single dependency override and compiler-plugin helper with clear, inline comments (`// why: ...`).
3. Cleaned out legacy dependencies and streamlined `core:database` to only capture `kspKotlinJvmProcessorClasspath`.
4. Changed includeConfigurations to use type-safe `setOf` instead of `listOf` to align with Gradle's `SetProperty` APIs.
- Verified that all static analysis checks pass successfully: `./gradlew spotlessCheck detekt` is 100% green.
- Validated end-to-end correctness by successfully compiling the app completely offline (`--offline`) and packaging the release UberJar.
## 2026-05-20 — Fixed Jekyll documentation site build and deployment in CI
- Created `docs/index.html` to automatically redirect root path requests (`/`) to the English directory (`/en/`).
- Updated `.github/workflows/docs-deploy.yml` to compile the Jekyll root site using `--baseurl /${{ github.event.repository.name }}` and setup Ruby with version `4.0.4` to match project release workflow conventions.
- Updated `.github/workflows/docs-release.yml` to compile both versioned and root Jekyll sites, assemble them into `build/final_site/` with Dokka HTML references, configure correct baseurls respectively, and setup Ruby with version `4.0.4`.
- Verified that local Gradle docs tasks (`generateDocsBundle`, `validateDocsBundle`, `publishDocsSite`) compile successfully and the redirect file is correctly populated in the output.
## 2026-05-20 — Optimized slow Flatpak CI jobs
- Restrained flatpakGradleGenerator to target only the runtimeClasspath configuration in desktopApp and build-logic:convention modules.
- Updated release.yml and reusable-check.yml to invoke only targeted tasks (:desktopApp:flatpakGradleGenerator and :build-logic:convention:flatpakGradleGenerator) instead of running the task on the root and all subprojects.
- Retained Matrix architecture runner configuration for build validity, as Skiko/Compose Desktop native artifacts are resolved dynamically based on host architecture.
- Cleaned up leftover speed-up workarounds: completely removed the Flatpak Gradle Generator plugin application and tasks from the root project and all other library/feature subprojects (`core:ble`, `core:common`, `core:database`, `core:model`, `core:navigation`, `core:proto`, and `feature:messaging`), including deleting the unused `flatpakKmpAndroidMeta` configuration from `feature:messaging`.
- Verified that local execution of `:desktopApp:flatpakGradleGenerator` runtimeClasspath resolution speed dropped from 46 seconds to 12 seconds, and all Spotless and Detekt linting checks passed.
## 2026-05-12 — Implemented Apple alignment for docs feature (FR-038)
- Branch: `feat/20260507-161858-app-docs-markdown`
- Gap analysis against `meshtastic-apple` completed. Implemented 4 alignment items:
1. Per-page TOC icons via `DocPageIconResolver.kt` mapping `iconId` to `MeshtasticIcons`
2. New `docs/user/signal-meter.md` (RSSI vs SNR, bar-level criteria, LoRa signal concepts)
3. New `docs/user/units-and-locale.md` (automatic metric/imperial via `MetricFormatter`)
4. New `.github/workflows/docs-staleness.yml` (advisory PR comments for UI changes without doc updates)
- Added `iconId: String?` field to `DocPage` and `KeywordIndexEntry` models
- Updated `DocBundleLoader` with iconId for all 24 pages plus 2 new entries (signal-meter, units-and-locale)
- Updated `DocsBrowserScreen` to show leading icons in TOC list items
- Marked T061-T085 as completed in tasks.md (were implemented in prior session)
- Added Phase 9 (T200-T206) for Apple alignment tasks — all marked complete
- Skipped Apple-only features: watch, carplay, translate, TipKit, SwiftData docs
- Verified: `spotlessApply`, `detekt`, `assembleDebug`, `compileKotlinJvm` — all green
## 2026-05-11 — Migrated feature/intro UI to commonMain
- Moved intro onboarding UI composables and nav graph from `feature/intro/src/androidMain/` into `feature/intro/src/commonMain/`, adding shared `IntroPermissions` and `IntroSettingsNavigator` interfaces plus a common `introGraph` Navigation 3 extension.
- Refactored `AppIntroductionScreen` into a thin Android host that provides Android permission/settings adapters via composition locals, and added `AndroidIntroPermissions`, `AndroidIntroSettingsNavigator`, and JVM desktop no-op stubs.

View File

@@ -34,7 +34,7 @@ Validate the implementation against its specification artifacts (`spec.md`, `pla
Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root.
1. **Script succeeds** (on a feature branch): Parse JSON for FEATURE_DIR. Set `FEATURE_BRANCH = true`. Proceed to next step.
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/NNN-*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
Derive absolute paths:

View File

@@ -18,7 +18,7 @@ git submodule update --init
./gradlew :core:data:allTests
# Single module tests (Android-only module like :app)
./gradlew :app:testFdroidDebugUnitTest
./gradlew :androidApp:testFdroidDebugUnitTest
# Cross-platform compilation check (no tests)
./gradlew kmpSmokeCompile
@@ -46,7 +46,7 @@ KMP modules have different task names than pure-Android modules. Using the wrong
## Quick Reference
- **Architecture**: KMP project (Android, Desktop, iOS). Business logic in `commonMain`; platform shells (`app/`, `desktop/`) wire DI and host UI. See `AGENTS.md` and `.skills/kmp-architecture/`.
- **Architecture**: KMP project (Android, Desktop, iOS). Business logic in `commonMain`; platform shells (`androidApp/`, `desktopApp/`) wire DI and host UI. See `AGENTS.md` and `.skills/kmp-architecture/`.
- **Flavors**: `fdroid` (OSS) / `google` (Maps + DataDog). Only one installable at a time (different signing keys).
- **Verify before push**: Run `./gradlew spotlessApply detekt assembleDebug test allTests`, then confirm CI with `gh pr checks <PR>`.
- **Strings**: `stringResource(Res.string.key)` — run `python3 scripts/sort-strings.py` after adding strings.

View File

@@ -5,7 +5,7 @@ excludeAgent: "code-review"
# CI Workflow Rules
- Prefer explicit Gradle task paths (`app:lintFdroidDebug`) over shorthand (`lintDebug`).
- Prefer explicit Gradle task paths (`androidApp:lintFdroidDebug`) over shorthand (`lintDebug`).
- CI uses `.github/ci-gradle.properties` — don't assume local `gradle.properties` values.
- CI passes `-Pci=true` to enable full processor usage via `maxParallelForks`.
- Use `fetch-depth: 0` only where needed (spotless ratcheting, version code). Use `fetch-depth: 1` otherwise.

90
.github/workflows/docs-deploy.yml vendored Normal file
View File

@@ -0,0 +1,90 @@
name: Deploy Documentation
on:
push:
branches: [main]
paths:
# Dokka sources (KDoc in source files)
- 'androidApp/src/**'
- 'core/**/src/**'
- 'feature/**/src/**'
- 'desktopApp/src/**'
# Docs site sources
- 'docs/**'
- 'feature/docs/**'
# Build infrastructure
- 'build-logic/**'
- 'build.gradle.kts'
- 'settings.gradle.kts'
- '.github/workflows/docs-deploy.yml'
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: true
fetch-depth: 0
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml --no-configuration-cache
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0.4'
bundler-cache: true
working-directory: docs
- name: Generate Docs Site
run: ./gradlew generateDocsBundle validateDocsBundle publishDocsSite -Pdocs.channel=root -Pci=true
- name: Compile Jekyll Docs Site
run: |
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
--source build/_site \
--destination build/jekyll_site \
--baseurl /${{ github.event.repository.name }}
touch build/jekyll_site/.nojekyll
- name: Assemble Pages artifact
run: |
# Copy Dokka output into compiled jekyll_site/api/
cp -r build/dokka/html build/jekyll_site/api
- name: Upload Pages Artifact
uses: actions/upload-pages-artifact@v5
with:
path: build/jekyll_site/
deploy:
if: github.repository == 'meshtastic/Meshtastic-Android'
needs: build
runs-on: ubuntu-24.04-arm
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

403
.github/workflows/docs-governance.yml vendored Normal file
View File

@@ -0,0 +1,403 @@
name: UI & Docs Governance
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled, unlabeled]
schedule:
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
workflow_dispatch:
permissions:
contents: read
pull-requests: write
jobs:
# ── Job 1: Staleness — flag PRs that change UI without updating docs ────────
staleness:
name: Docs staleness check
runs-on: ubuntu-24.04-arm
if: >-
github.event_name == 'pull_request'
&& !contains(github.event.pull_request.labels.*.name, 'skip-docs-check')
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect changed files
id: changed
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
changed=$(git diff --name-only "$BASE" "$HEAD")
views_changed=$(echo "$changed" | grep -E \
'^feature/.*/src/commonMain/.*/(ui|component|screen)/|^feature/.*/src/androidMain/.*/ui/|^core/ui/src/commonMain/' \
| grep -v 'Test\|Preview\|__Snapshots__' || true)
docs_changed=$(echo "$changed" | grep -E '^docs/en/(user|developer)/' || true)
echo "views_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$views_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "docs_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$docs_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
if [[ -n "$views_changed" && -z "$docs_changed" ]]; then
echo "stale=true" >> "$GITHUB_OUTPUT"
else
echo "stale=false" >> "$GITHUB_OUTPUT"
fi
- name: Post warning comment
if: steps.changed.outputs.stale == 'true'
uses: actions/github-script@v9
with:
script: |
const viewsChanged = `${{ steps.changed.outputs.views_changed }}`.trim();
const body = [
'## 📄 Docs staleness check — advisory',
'',
'This PR modifies user-facing UI source files but does not update any page under `docs/en/user/` or `docs/en/developer/`.',
'',
'> ⚠️ Doc changes propagate to **3 consumers**: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in `docs/en/` automatically flows to all three.',
'',
'**Changed source files:**',
'```',
viewsChanged,
'```',
'',
'**What to check:**',
'| Changed area | Likely doc page |',
'|---|---|',
'| `feature/messaging/` | `docs/en/user/messages-and-channels.md` |',
'| `feature/node/` | `docs/en/user/nodes.md` or `docs/en/user/node-metrics.md` |',
'| `feature/map/` | `docs/en/user/map-and-waypoints.md` |',
'| `feature/connections/` | `docs/en/user/connections.md` |',
'| `feature/settings/` | `docs/en/user/settings-radio-user.md` or `docs/en/user/settings-module-admin.md` |',
'| `feature/firmware/` | `docs/en/user/firmware.md` |',
'| `feature/intro/` | `docs/en/user/onboarding.md` |',
'| `feature/discovery/` | `docs/en/user/discovery.md` |',
'| `feature/docs/` | Internal docs infrastructure |',
'| `core/ui/` | `docs/en/developer/codebase.md` or component-specific user pages |',
'',
'**New page checklist** (if adding a new doc page):',
'1. Create the `.md` file in `docs/en/user/` or `docs/en/developer/` with `last_updated` frontmatter',
'2. Register in `DocBundleLoader.kt` with string resources (in-app browser)',
'3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed',
'',
'If this PR does **not** require a doc update (e.g., internal refactor, bug fix, test change), add the **`skip-docs-check`** label to dismiss this check.',
'',
'> **Cross-platform note:** This check is advisory while doc coverage matures. Both Android and Apple repos use the same `skip-docs-check` label and advisory severity. See `meshtastic/design` standards for shared conventions.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Docs staleness check')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Dismiss stale comment when docs are updated
if: steps.changed.outputs.stale == 'false'
uses: actions/github-script@v9
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Docs staleness check')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: '## ✅ Docs staleness check passed\n\nThis PR includes updates to `docs/en/` alongside the source changes. Thank you!',
});
}
- name: Advisory status
if: steps.changed.outputs.stale == 'true'
run: |
echo "::warning::UI source files changed without corresponding docs/en/ updates."
echo "Add the 'skip-docs-check' label if this PR does not require a doc update."
echo "NOTE: This check is advisory while docs coverage matures across platforms."
echo "To upgrade to blocking, change this step to 'exit 1'."
# ── Job 2: Quality gates — link validation, coverage, registry, freshness ───
validate:
name: Docs quality gates
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24"
- name: Validate internal links
run: node scripts/validate-doc-links.js docs/en
- name: Check doc coverage
run: node scripts/check-doc-coverage.js .
- name: Validate DocBundleLoader registry
run: |
loader="feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt"
missing=0
for f in docs/en/user/*.md docs/en/developer/*.md; do
slug=$(basename "$f" .md)
if ! grep -q "\"$slug\"" "$loader"; then
echo "ERROR: $slug not registered in DocBundleLoader.kt"
missing=$((missing + 1))
fi
done
if [ "$missing" -gt 0 ]; then
echo ""
echo "FAILED: $missing page(s) missing from DocBundleLoader.kt in-app index."
exit 1
fi
echo "All doc pages registered in DocBundleLoader."
- name: Check doc freshness
# Advisory — warns on stale pages but does not block PRs
continue-on-error: true
run: node scripts/check-doc-freshness.js docs --max-age-days=180
# ── Job 3: Preview staleness — flag UI changes without preview updates ──────
preview-staleness:
name: Preview staleness check
runs-on: ubuntu-24.04-arm
if: >-
github.event_name == 'pull_request'
&& !contains(github.event.pull_request.labels.*.name, 'skip-preview-check')
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Detect changed files
id: changed
run: |
BASE="${{ github.event.pull_request.base.sha }}"
HEAD="${{ github.event.pull_request.head.sha }}"
changed=$(git diff --name-only "$BASE" "$HEAD")
# UI composables changed (screens, components — excluding tests and previews)
ui_changed=$(echo "$changed" | grep -E \
'^(feature|core/ui)/.*/src/commonMain/.*/.*\.(kt)$' \
| grep -E '/(ui|component|screen)/' \
| grep -v 'Test\|Preview\|__Snapshots__' || true)
# Preview files changed
preview_changed=$(echo "$changed" | grep -E 'Preview.*\.kt$' || true)
# Screenshot test files changed
screenshot_tests_changed=$(echo "$changed" | grep -E '^screenshot-tests/src/screenshotTest/.*\.kt$' || true)
# Reference images changed
refs_changed=$(echo "$changed" | grep -E '^screenshot-tests/src/screenshotTestDebug/reference/.*\.png$' || true)
echo "ui_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$ui_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "preview_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$preview_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "screenshot_tests_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$screenshot_tests_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "refs_changed<<EOF" >> "$GITHUB_OUTPUT"
echo "$refs_changed" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
# Preview staleness: UI changed but no preview updates
if [[ -n "$ui_changed" && -z "$preview_changed" ]]; then
echo "preview_stale=true" >> "$GITHUB_OUTPUT"
else
echo "preview_stale=false" >> "$GITHUB_OUTPUT"
fi
# Screenshot staleness: previews changed but no reference image updates
if [[ -n "$preview_changed" && -z "$refs_changed" ]]; then
echo "screenshot_stale=true" >> "$GITHUB_OUTPUT"
else
echo "screenshot_stale=false" >> "$GITHUB_OUTPUT"
fi
- name: Post preview advisory
if: steps.changed.outputs.preview_stale == 'true'
uses: actions/github-script@v9
with:
script: |
const uiChanged = `${{ steps.changed.outputs.ui_changed }}`.trim();
const body = [
'## 🖼️ Preview staleness check — advisory',
'',
'This PR modifies UI composables but does not update any `*Previews.kt` files.',
'',
'> Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.',
'',
'**Changed UI files:**',
'```',
uiChanged,
'```',
'',
'**What to check:**',
'| Pattern | Preview file convention |',
'|---|---|',
'| `feature/{name}/…/ui/` or `component/` | `feature/{name}/…/*Previews.kt` |',
'| `core/ui/…/` | `core/ui/…/` (previews colocated) |',
'',
'**Adding previews checklist:**',
'1. Create or update a `*Previews.kt` file in the same module with `@PreviewLightDark`',
'2. Add `@Suppress("PreviewPublic")` if the preview is consumed by screenshot-tests',
'3. Add corresponding `@PreviewTest` function in `screenshot-tests/src/screenshotTest/`',
'4. Run `./gradlew :screenshot-tests:updateDebugScreenshotTest` to generate reference images',
'',
'If this PR does **not** require preview updates (e.g., logic-only change, non-visual refactor), add the **`skip-preview-check`** label to dismiss.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Preview staleness check')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Post screenshot advisory
if: steps.changed.outputs.screenshot_stale == 'true'
uses: actions/github-script@v9
with:
script: |
const previewChanged = `${{ steps.changed.outputs.preview_changed }}`.trim();
const body = [
'## 📸 Screenshot reference staleness — advisory',
'',
'This PR modifies preview composables but does not update screenshot reference images.',
'',
'> Reference images in `screenshot-tests/src/screenshotTestDebug/reference/` must be regenerated when previews change, or `validateDebugScreenshotTest` will fail.',
'',
'**Changed preview files:**',
'```',
previewChanged,
'```',
'',
'**How to update:**',
'```bash',
'./gradlew :screenshot-tests:updateDebugScreenshotTest',
'```',
'Then commit the updated reference PNGs.',
'',
'If this change is intentionally preview-only (e.g., adding a preview that doesn\'t need a test yet), add the **`skip-preview-check`** label.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes('Screenshot reference staleness')
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Dismiss comments when resolved
if: >-
steps.changed.outputs.preview_stale == 'false'
&& steps.changed.outputs.screenshot_stale == 'false'
uses: actions/github-script@v9
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
for (const marker of ['Preview staleness check', 'Screenshot reference staleness']) {
const existing = comments.find(c =>
c.user.login === 'github-actions[bot]' &&
c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: `## ✅ ${marker} passed\n\nPreview and screenshot references are up to date.`,
});
}
}

111
.github/workflows/docs-release.yml vendored Normal file
View File

@@ -0,0 +1,111 @@
name: Docs Release
on:
push:
tags:
- 'v*.*.*'
- '!v*-*'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: true
fetch-depth: 0
- name: Extract Version
id: version
run: |
TAG=${GITHUB_REF#refs/tags/v}
echo "version=$TAG" >> $GITHUB_OUTPUT
echo "Deploying docs for version: $TAG"
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '4.0.4'
bundler-cache: true
working-directory: docs
# Build root docs site (/ on Pages)
- name: Build Root Docs Site
run: ./gradlew generateDocsBundle publishDocsSite -Pdocs.channel=root -Pci=true
# Build versioned docs (/vX.Y.Z/ on Pages)
- name: Build Versioned Docs
run: ./gradlew generateDocsBundle publishDocsSite -Pdocs.channel=release -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
# Build Dokka API reference (/api/ on Pages)
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml --no-configuration-cache
- name: Compile Jekyll Sites
run: |
# Build versioned Jekyll site
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
--source build/_site/v${{ steps.version.outputs.version }} \
--destination build/jekyll_release \
--baseurl /${{ github.event.repository.name }}/v${{ steps.version.outputs.version }}
# Move versioned source folder out of root source folder to avoid nested build issues
mv build/_site/v${{ steps.version.outputs.version }} build/v_temp
# Build root Jekyll site
BUNDLE_GEMFILE=docs/Gemfile bundle exec jekyll build \
--source build/_site \
--destination build/jekyll_root \
--baseurl /${{ github.event.repository.name }}
- name: Assemble Pages artifact
run: |
# Create final site directory
mkdir -p build/final_site
# Copy root compiled site
cp -r build/jekyll_root/* build/final_site/
# Copy release compiled site to vX.Y.Z/
mkdir -p build/final_site/v${{ steps.version.outputs.version }}
cp -r build/jekyll_release/* build/final_site/v${{ steps.version.outputs.version }}/
# Copy Dokka output into final_site/api/
cp -r build/dokka/html build/final_site/api
# Disable GitHub Pages' own Jekyll build
touch build/final_site/.nojekyll
- name: Upload Pages Artifact
uses: actions/upload-pages-artifact@v5
with:
path: build/final_site/
deploy:
if: github.repository == 'meshtastic/Meshtastic-Android'
needs: build
runs-on: ubuntu-24.04-arm
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View File

@@ -1,83 +0,0 @@
# This workflow builds and deploys the Dokka documentation to GitHub Pages.
name: Deploy Documentation
on:
push:
branches:
- main
paths:
# Only rebuild docs when source code changes (Dokka generates from KDoc)
- 'app/src/**'
- 'core/**/src/**'
- 'feature/**/src/**'
- 'desktop/src/**'
- 'build-logic/**'
- 'build.gradle.kts'
- 'settings.gradle.kts'
- '.github/workflows/docs.yml'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
inputs:
ref:
description: 'The branch, tag or SHA to checkout'
required: false
type: string
# Allow this workflow to be called from other workflows
workflow_call:
inputs:
ref:
description: 'The branch, tag or SHA to checkout'
required: false
type: string
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment; cancel queued runs since only the latest
# main state matters for documentation.
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
build-docs:
if: github.repository == 'meshtastic/Meshtastic-Android'
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
submodules: 'recursive'
ref: ${{ inputs.ref || '' }}
- name: Gradle Setup
uses: ./.github/actions/gradle-setup
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
- name: Build Dokka HTML documentation
run: ./gradlew dokkaGeneratePublicationHtml
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: build/dokka/html
deploy:
if: github.repository == 'meshtastic/Meshtastic-Android'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-24.04-arm
needs: build-docs
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View File

@@ -21,6 +21,5 @@ jobs:
with:
run_lint: true
run_unit_tests: false
run_desktop_builds: false
upload_artifacts: true
secrets: inherit

View File

@@ -30,9 +30,9 @@ jobs:
- '.github/workflows/**'
- '.github/actions/**'
# Product modules validated by reusable-check
- 'app/**'
- 'androidApp/**'
- 'baselineprofile/**'
- 'desktop/**'
- 'desktopApp/**'
- 'core/**'
- 'feature/**'
- 'screenshot-tests/**'
@@ -96,7 +96,7 @@ jobs:
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
# We disable coverage and desktop builds for PRs to keep feedback fast
# (< 10 mins). Desktop compilation is already covered by the :desktop:test
# (< 10 mins). Desktop compilation is already covered by the :desktopApp:test
# task in the shard-app test shard.
validate-and-build:
needs: check-changes
@@ -106,7 +106,6 @@ jobs:
run_lint: true
run_unit_tests: true
run_coverage: false
run_desktop_builds: false
upload_artifacts: true
secrets: inherit

View File

@@ -150,9 +150,9 @@ jobs:
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
rm -f ./app/google-services.json
echo $GSERVICES > ./app/google-services.json
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
rm -f ./androidApp/google-services.json
echo $GSERVICES > ./androidApp/google-services.json
echo $KEYSTORE | base64 -di > ./androidApp/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
echo "datadogApplicationId=$DATADOG_APPLICATION_ID" >> ./secrets.properties
echo "datadogClientToken=$DATADOG_CLIENT_TOKEN" >> ./secrets.properties
@@ -172,14 +172,14 @@ jobs:
run: bundle exec fastlane internal
- name: List outputs
run: ls -R app/build/outputs/
run: ls -R androidApp/build/outputs/
- name: Upload Google AAB artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: google-aab
path: app/build/outputs/bundle/googleRelease/app-google-release.aab
path: androidApp/build/outputs/bundle/googleRelease/androidApp-google-release.aab
retention-days: 1
- name: Upload Google APK artifact
@@ -187,20 +187,20 @@ jobs:
uses: actions/upload-artifact@v7
with:
name: google-apk
path: app/build/outputs/apk/google/release/*.apk
path: androidApp/build/outputs/apk/google/release/*.apk
retention-days: 1
- name: Attest Google AAB provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/bundle/googleRelease/app-google-release.aab
subject-path: androidApp/build/outputs/bundle/googleRelease/androidApp-google-release.aab
- name: Attest Google APK provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/google/release/*.apk
subject-path: androidApp/build/outputs/apk/google/release/*.apk
release-fdroid:
runs-on: ubuntu-24.04
@@ -229,7 +229,7 @@ jobs:
KEYSTORE_FILENAME: ${{ secrets.KEYSTORE_FILENAME }}
KEYSTORE_PROPERTIES: ${{ secrets.KEYSTORE_PROPERTIES }}
run: |
echo $KEYSTORE | base64 -di > ./app/$KEYSTORE_FILENAME
echo $KEYSTORE | base64 -di > ./androidApp/$KEYSTORE_FILENAME
echo "$KEYSTORE_PROPERTIES" > ./keystore.properties
- name: Setup Fastlane
@@ -245,21 +245,21 @@ jobs:
run: bundle exec fastlane fdroid_build
- name: List outputs
run: ls -R app/build/outputs/
run: ls -R androidApp/build/outputs/
- name: Upload F-Droid APK artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: fdroid-apk
path: app/build/outputs/apk/fdroid/release/*.apk
path: androidApp/build/outputs/apk/fdroid/release/*.apk
retention-days: 1
- name: Attest F-Droid APK provenance
if: success()
uses: actions/attest-build-provenance@v4
with:
subject-path: app/build/outputs/apk/fdroid/release/*.apk
subject-path: androidApp/build/outputs/apk/fdroid/release/*.apk
release-desktop:
if: ${{ inputs.build_desktop }}
@@ -307,13 +307,13 @@ jobs:
# `-PaboutLibraries.release=true` as member access on `-PaboutLibraries`,
# splitting the token and feeding `.release=true` to Gradle as a task name.
run: >
./gradlew :desktop:packageReleaseDistributionForCurrentOS
${{ contains(runner.os, 'macOS') && ':desktop:packageReleaseUberJarForCurrentOS' || '' }}
./gradlew :desktopApp:packageReleaseDistributionForCurrentOS
${{ contains(runner.os, 'macOS') && ':desktopApp:packageReleaseUberJarForCurrentOS' || '' }}
'-PaboutLibraries.release=true' --no-daemon
- name: List Desktop Binaries
if: runner.os == 'Linux' || runner.os == 'macOS'
run: ls -R desktop/build/compose/binaries/main-release
run: ls -R desktopApp/build/compose/binaries/main-release
- name: Upload Desktop Artifacts
if: always()
@@ -321,13 +321,13 @@ jobs:
with:
name: desktop-${{ runner.os }}-${{ runner.arch }}
path: |
desktop/build/compose/binaries/main-release/*/*.dmg
desktop/build/compose/binaries/main-release/*/*.msi
desktop/build/compose/binaries/main-release/*/*.exe
desktop/build/compose/binaries/main-release/*/*.deb
desktop/build/compose/binaries/main-release/*/*.rpm
desktop/build/compose/binaries/main-release/*/*.AppImage
desktop/build/compose/jars/*-release.jar
desktopApp/build/compose/binaries/main-release/*/*.dmg
desktopApp/build/compose/binaries/main-release/*/*.msi
desktopApp/build/compose/binaries/main-release/*/*.exe
desktopApp/build/compose/binaries/main-release/*/*.deb
desktopApp/build/compose/binaries/main-release/*/*.rpm
desktopApp/build/compose/binaries/main-release/*/*.AppImage
desktopApp/build/compose/jars/*-release.jar
retention-days: 1
if-no-files-found: ignore
@@ -336,13 +336,13 @@ jobs:
uses: actions/attest-build-provenance@v4
with:
subject-path: |
desktop/build/compose/binaries/main-release/*/*.dmg
desktop/build/compose/binaries/main-release/*/*.msi
desktop/build/compose/binaries/main-release/*/*.exe
desktop/build/compose/binaries/main-release/*/*.deb
desktop/build/compose/binaries/main-release/*/*.rpm
desktop/build/compose/binaries/main-release/*/*.AppImage
desktop/build/compose/jars/*-release.jar
desktopApp/build/compose/binaries/main-release/*/*.dmg
desktopApp/build/compose/binaries/main-release/*/*.msi
desktopApp/build/compose/binaries/main-release/*/*.exe
desktopApp/build/compose/binaries/main-release/*/*.deb
desktopApp/build/compose/binaries/main-release/*/*.rpm
desktopApp/build/compose/binaries/main-release/*/*.AppImage
desktopApp/build/compose/jars/*-release.jar
create-flatpak-src:
if: ${{ inputs.build_flatpak_src }}
@@ -370,35 +370,20 @@ jobs:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: 'true'
- name: Python Setup
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install ast-grep
run: pip install ast-grep-cli
# Remove Android/iOS targets and other non-desktop bits
# that would break the flatpakGradleGenerator
- name: Prepare Offline Desktop Build
run: ./scripts/desktop-only-prep.sh
- name: Generate Flatpak Sources
env:
DESKTOP_ONLY: true
run: >
./gradlew :build-logic:convention:flatpakGradleGenerator flatpakGradleGenerator
--no-configuration-cache --refresh-dependencies --no-parallel
./gradlew :combineFlatpakSources
--no-configuration-cache
- name: List Flatpak source files
run: ls -R flatpak-sources-*.json
run: ls -R flatpak-sources.json
- name: Upload Flatpak source artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: flatpak-multisrc-${{ runner.arch }}
path: flatpak-sources-*.json
path: flatpak-sources.json
retention-days: 1
release-flatpak-src:
@@ -416,24 +401,12 @@ jobs:
run: ls -R flatpak-multisrc/
- name: Validate Flatpak source files
run: jq empty flatpak-multisrc/*/*.json
run: jq empty flatpak-multisrc/*/flatpak-sources.json
- name: Combine Flatpak source files
run: >
jq -s 'add | unique_by(.dest + "/" + .["dest-filename"] + "/" + (.["only-arches"] | tostring))'
flatpak-multisrc/*/*.json
> flatpak-sources-combined.json
- name: Add mirror-urls for Maven Central
run: >
jq '[.[] | if (.url | test("^https://repo\\.maven\\.apache\\.org/maven2/")) then . + {"mirror-urls": [(.url | sub("^https://repo\\.maven\\.apache\\.org/maven2/"; "https://maven-central.storage-download.googleapis.com/maven2/"))]} else . end]'
flatpak-sources-combined.json
> flatpak-sources-central.json
- name: Add mirror-urls for Gradle Plugin Portal
run: >
jq '[.[] | if (.url | test("^https://plugins\\.gradle\\.org/m2/")) then . + {"mirror-urls": [(.url | sub("^https://plugins\\.gradle\\.org/m2/"; "https://maven.aliyun.com/repository/gradle-plugin/"))]} else . end]'
flatpak-sources-central.json
flatpak-multisrc/*/flatpak-sources.json
> flatpak-sources.json
- name: Upload combined Flatpak source artifact

View File

@@ -12,9 +12,6 @@ on:
run_coverage:
type: boolean
default: true
run_desktop_builds:
type: boolean
default: true
run_desktop_flatpak_src:
type: boolean
default: false
@@ -97,7 +94,7 @@ jobs:
- name: Lint, Analysis & KMP Smoke Compile
if: inputs.run_lint == true
run: ./gradlew spotlessCheck detekt app:lintFdroidDebug app:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
run: ./gradlew spotlessCheck detekt androidApp:lintFdroidDebug androidApp:lintGoogleDebug core:barcode:lintFdroidDebug core:barcode:lintGoogleDebug core:api:lintDebug kmpSmokeCompile -Pci=true --continue --scan
- name: KMP Smoke Compile (lint skipped)
if: inputs.run_lint == false
@@ -155,13 +152,13 @@ jobs:
# See: https://github.com/meshtastic/Meshtastic-Android/issues/3231
echo "── Step 1: Verify aboutlibraries.json determinism ──"
rm -f app/src/main/resources/aboutlibraries.json
./gradlew :app:exportLibraryDefinitions -Pci=true --no-configuration-cache
cp app/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run1.json
rm -f androidApp/src/main/resources/aboutlibraries.json
./gradlew :androidApp:exportLibraryDefinitions -Pci=true --no-configuration-cache
cp androidApp/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run1.json
rm -f app/src/main/resources/aboutlibraries.json
./gradlew :app:exportLibraryDefinitions -Pci=true --no-configuration-cache --rerun-tasks
cp app/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run2.json
rm -f androidApp/src/main/resources/aboutlibraries.json
./gradlew :androidApp:exportLibraryDefinitions -Pci=true --no-configuration-cache --rerun-tasks
cp androidApp/src/main/resources/aboutlibraries.json /tmp/aboutlibraries-run2.json
if ! diff -q /tmp/aboutlibraries-run1.json /tmp/aboutlibraries-run2.json; then
echo "::error::aboutlibraries.json is NOT deterministic across runs!"
@@ -171,9 +168,9 @@ jobs:
echo "✅ aboutlibraries.json is deterministic"
echo "── Step 2: Build fdroid release APK ──"
./gradlew :app:assembleFdroidRelease -Pci=true -Pmeshtastic.disableAbiSplits=true --no-configuration-cache
./gradlew :androidApp:assembleFdroidRelease -Pci=true -Pmeshtastic.disableAbiSplits=true --no-configuration-cache
APK=$(find app/build/outputs/apk/fdroid/release -name "*.apk" | head -1)
APK=$(find androidApp/build/outputs/apk/fdroid/release -name "*.apk" | head -1)
if [ -z "$APK" ]; then
echo "::error::No fdroid release APK found"
exit 1
@@ -298,7 +295,7 @@ jobs:
# Tests are split into 3 shards that run in parallel:
# shard-core: core:* KMP module tests (allTests)
# shard-feature: feature:* KMP module tests (allTests)
# shard-app: Pure-Android/JVM tests (app, desktop, core:barcode, etc.)
# shard-app: Pure-Android/JVM tests (androidApp, desktopApp, core:barcode, etc.)
test-shards:
runs-on: ubuntu-24.04
permissions:
@@ -362,17 +359,17 @@ jobs:
:feature:settings:koverXmlReport
- name: shard-app
tasks: >-
:app:testFdroidDebugUnitTest
:app:testGoogleDebugUnitTest
:desktop:test
:androidApp:testFdroidDebugUnitTest
:androidApp:testGoogleDebugUnitTest
:desktopApp:test
:core:barcode:testFdroidDebugUnitTest
:core:barcode:testGoogleDebugUnitTest
kover: >-
:app:koverXmlReportFdroidDebug
:app:koverXmlReportGoogleDebug
:androidApp:koverXmlReportFdroidDebug
:androidApp:koverXmlReportGoogleDebug
:core:barcode:koverXmlReportFdroidDebug
:core:barcode:koverXmlReportGoogleDebug
:desktop:koverXmlReport
:desktopApp:koverXmlReport
steps:
- name: Checkout code
@@ -450,14 +447,14 @@ jobs:
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Android APKs
run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
run: ./gradlew androidApp:assembleFdroidDebug androidApp:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
- name: Upload debug artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk
path: androidApp/build/outputs/apk/*/debug/*.apk
retention-days: 7
- name: Report App Size
@@ -466,7 +463,7 @@ jobs:
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
find androidApp/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
# ── Desktop Build ───────────────────────────────────────────────────
build-desktop:
@@ -498,14 +495,14 @@ jobs:
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- name: Build Desktop
run: ./gradlew :desktop:createDistributable -Pci=true --scan
run: ./gradlew :desktopApp:createDistributable -Pci=true --scan
- name: Upload Desktop artifact
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: desktop-app-${{ runner.os }}-${{ runner.arch }}
path: desktop/build/compose/binaries/main/app/
path: desktopApp/build/compose/binaries/main/app/
retention-days: 7
# ── Flatpak Sources ───────────────────────────────────────────────────
@@ -536,33 +533,18 @@ jobs:
with:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: true
- name: Python Setup
uses: actions/setup-python@v6
with:
python-version: '3.x'
- name: Install ast-grep
run: pip install ast-grep-cli
# Remove Android/iOS targets and other non-desktop bits
# that would break the flatpakGradleGenerator
- name: Prepare Offline Desktop Build
run: ./scripts/desktop-only-prep.sh
- name: Generate Flatpak Sources
env:
DESKTOP_ONLY: true
run: >
./gradlew :build-logic:convention:flatpakGradleGenerator flatpakGradleGenerator
--no-configuration-cache --refresh-dependencies --no-parallel
./gradlew :combineFlatpakSources
--no-configuration-cache --refresh-dependencies
- run: ls -lah flatpak-sources-*.json
- run: ls -lah flatpak-sources.json
- name: Upload Flatpak Sources
if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: flatpak-sources-${{ runner.arch }}
path: flatpak-sources-*.json
path: flatpak-sources.json
retention-days: 7

View File

@@ -22,7 +22,7 @@ jobs:
- name: Update firmware releases list
id: firmware
run: |
firmware_file_path="app/src/main/assets/firmware_releases.json"
firmware_file_path="androidApp/src/main/assets/firmware_releases.json"
temp_firmware_file="/tmp/new_firmware_releases.json"
echo "Fetching latest firmware releases..."
@@ -51,7 +51,7 @@ jobs:
- name: Update hardware list
id: hardware
run: |
hardware_file_path="app/src/main/assets/device_hardware.json"
hardware_file_path="androidApp/src/main/assets/device_hardware.json"
temp_hardware_file="/tmp/new_device_hardware.json"
echo "Fetching latest device hardware data..."
@@ -172,11 +172,12 @@ jobs:
base: 'main'
delete-branch: true
add-paths: |
app/src/main/assets/firmware_releases.json
app/src/main/assets/device_hardware.json
androidApp/src/main/assets/firmware_releases.json
androidApp/src/main/assets/device_hardware.json
fastlane/metadata/android/**
**/strings.xml
**/README.md
docs/**/*.md
labels: |
automation
l10n

53
.github/workflows/sync-android-docs.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Sync Android App Documentation
on:
schedule:
- cron: "0 0 * * 0" # Every Sunday at midnight UTC
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout meshtastic/meshtastic
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Clone meshtastic/Meshtastic-Android
run: |
git clone --depth=1 --branch main https://github.com/meshtastic/Meshtastic-Android.git /tmp/Meshtastic-Android
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: ".nvmrc"
- name: Install cwebp
run: sudo apt-get update && sudo apt-get install -y webp
- name: Run sync script
run: node /tmp/Meshtastic-Android/scripts/sync-android-docs.js /tmp/Meshtastic-Android --convert-webp
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "docs: sync latest Android app documentation"
branch: sync/android-docs
delete-branch: true
title: "docs: sync latest Android app documentation"
body: |
This PR was automatically created by the [sync-android-docs](../.github/workflows/sync-android-docs.yml) workflow.
It synchronizes Markdown documentation and images from [meshtastic/Meshtastic-Android](https://github.com/meshtastic/Meshtastic-Android) into this repository:
- Markdown files → `docs/software/android/`
- Image files → `static/img/android/docs/`
Image paths in Markdown are rewritten to use the Docusaurus `/img/android/docs/` static path.
Screenshots are converted to WebP for optimal site performance.

View File

@@ -101,10 +101,14 @@ jobs:
| sed -e :a -e '/^\n*$/{$d;N;ba}'
}
# Helper: generate notes using git log (works between any two refs)
# Helper: generate notes using GitHub compare API (works between any two refs)
generate_notes_git() {
local from_ref="$1" to_ref="$2"
git log --no-merges --format="* %s by @%an in [\`%h\`](https://github.com/${REPO}/commit/%H)" "$from_ref".."$to_ref" 2>/dev/null || true
gh api "repos/${REPO}/compare/${from_ref}...${to_ref}" \
--jq '.commits[]
| select(.parents | length == 1)
| "* \(.commit.message | split("\n")[0]) by \(.commit.author.name) (@\(.author.login // .commit.author.name)) in [`\(.sha[0:9])`](https://github.com/'"${REPO}"'/commit/\(.sha))"' \
2>/dev/null || true
}
# Helper: demote ### headings to #### for nesting under channel sections

20
.gitignore vendored
View File

@@ -51,6 +51,12 @@ docs/screenshots/
build-and-install-android.sh
wireless-install.sh
# Generated docs artifacts
docs/_site/
docs/.jekyll-cache/
docs/.jekyll-metadata
docs/Gemfile.lock
# Git worktrees
.worktrees/
/firebase-debug.log.jdk/
@@ -63,6 +69,18 @@ firebase-debug.log
/coil/
/kable/
.opencode/
# Synced docs in composeResources (generated from docs/ source by syncDocsToComposeResources task)
feature/docs/src/commonMain/composeResources/files/docs/
# Synced translated docs (generated from docs/{locale}/ by syncTranslatedDocsToComposeResources task)
feature/docs/src/commonMain/composeResources/files/*/docs/
/desktop/bin/
/build-logic/convention/bin/
/.specify/extensions/.cache/
/.specify/extensions/.cache/
# Jekyll local config (comments out remote_theme for local builds)
docs/_config_local.yml
# Flatpak source manifests and repositories
flatpak-sources-*.json
flatpak-sources.json
offline-repository/

1
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "app proto submodule"]
path = core/proto/src/main/proto
url = https://github.com/meshtastic/protobufs.git
branch = master

View File

@@ -23,8 +23,8 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- [ ] **Compose Multiplatform Resources:** Ensure NO hardcoded strings. Must use `core:resources` (e.g., `stringResource(Res.string.key)` or asynchronous `getStringSuspend(Res.string.key)` for ViewModels/Coroutines). NEVER use blocking `getString()` in a coroutine.
- [ ] **String Formatting:** CMP only supports `%N$s` and `%N$d`. Flag any float formats (`%N$.1f`) in Compose string resources; they must be pre-formatted using `NumberFormatter.format()` from `core:common`. Use `MetricFormatter` for metric-specific displays (temperature, voltage, current, percent, humidity, pressure, SNR, RSSI).
- [ ] **Centralized Dialogs & Alerts:** Flag inline alert-rendering logic. Mandate the use of `AlertHost(alertManager)` or `SharedDialogs` from `core:ui/commonMain`.
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktop/tablet breakpoints (≥ 1200dp).
- [ ] **Placeholders:** Require `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktopApp/JVM features. No inline placeholders in feature modules.
- [ ] **Adaptive Layouts:** Verify use of `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support desktopApp/tablet breakpoints (≥ 1200dp).
### 3. Navigation & State
- [ ] **Shared Navigation Graphs:** Feature navigation graphs must be defined as extension functions on `EntryProviderScope<NavKey>` in `commonMain` (e.g., `fun EntryProviderScope<NavKey>.settingsGraph(...)`). Flag any graphs defined in platform-specific source sets.
@@ -56,8 +56,8 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
### 8. ProGuard / R8 Rules
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `app/proguard-rules.pro` (R8) and `desktop/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktop:runRelease` succeed.
- [ ] **New Dependencies:** If a new reflection-heavy dependency is added (DI, serialization, JNI, ServiceLoader), verify keep rules exist in **both** `androidApp/proguard-rules.pro` (R8) and `desktopApp/proguard-rules.pro` (ProGuard). The two files must stay aligned.
- [ ] **Release Smoke-Test:** For dependency or ProGuard rule changes, verify `assembleRelease` and `./gradlew :desktopApp:runRelease` succeed.
## Review Output Guidelines
1. **Be Specific & Constructive:** Provide exact file references and code snippets illustrating the required project pattern.

View File

@@ -4,9 +4,9 @@
Guidelines for building shared UI, adaptive layouts, and handling strings/resources in Meshtastic-Android. The codebase uses Material 3 Adaptive.
## 1. UI Components & Layouts
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktop/tablets.
- **Material 3 / Adaptive:** Use `currentWindowAdaptiveInfo(supportLargeAndXLargeWidth = true)` to support Large (1200dp) and XL (1600dp) breakpoints. Investigate 3-pane "Power User" scenes using Navigation 3 Scenes and draggable dividers for desktopApp/tablets.
- **Dialogs & Alerts:** Use centralized components like `AlertHost(alertManager)` from `core:ui/commonMain`. Do NOT trigger alerts inline or duplicate alert logic. Use `SharedDialogs(uiViewModel)` for general popups.
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktop/JVM features.
- **Placeholders:** Use `PlaceholderScreen(name)` from `core:ui/commonMain` for unimplemented desktopApp/JVM features.
- **Theme Picker:** Use `ThemePickerDialog` from `feature:settings/commonMain`.
- **Platform Implementations:** Inject platform-specific behavior (e.g., Map providers) via `CompositionLocal` from the `app` or `desktop` shells. Do not tightly couple Google Maps/osmdroid dependencies to `commonMain`.
@@ -58,4 +58,4 @@ Choose the right tool for the job:
## Reference Anchors
- **Shared Strings:** `core/resources/src/commonMain/composeResources/values/strings.xml`
- **Platform abstraction contract:** `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- **Provider wiring:** `app/src/main/kotlin/org/meshtastic/app/MainActivity.kt`
- **Provider wiring:** `androidApp/src/main/kotlin/org/meshtastic/app/MainActivity.kt`

View File

@@ -302,6 +302,47 @@ distance_filters_description
distance_measurements
distance_measurements_description
dns
### DOC ###
doc_clear_search
doc_keywords_connections
doc_keywords_desktop
doc_keywords_discovery
doc_keywords_firmware
doc_keywords_map
doc_keywords_measurement
doc_keywords_messages
doc_keywords_mqtt
doc_keywords_node_metrics
doc_keywords_nodes
doc_keywords_onboarding
doc_keywords_settings_module
doc_keywords_settings_radio
doc_keywords_signal_meter
doc_keywords_tak
doc_keywords_telemetry
doc_keywords_translate
doc_keywords_units
doc_search_placeholder
doc_section_developer
doc_section_user
doc_title_connections
doc_title_desktop
doc_title_discovery
doc_title_firmware
doc_title_map
doc_title_measurement
doc_title_messages
doc_title_mqtt
doc_title_node_metrics
doc_title_nodes
doc_title_onboarding
doc_title_settings_module
doc_title_settings_radio
doc_title_signal_meter
doc_title_tak
doc_title_telemetry
doc_title_translate
doc_title_units
done
dont_show_again_for_device
double_tap_as_button_press
@@ -483,6 +524,7 @@ hardware
hardware_model
heading
heartbeat
help_and_documentation
hide_layer
hide_password
history_return_max
@@ -1143,8 +1185,19 @@ tak_role_sniper
tak_role_teamlead
tak_role_teammember
tak_role_unspecified
tak_server
tak_server_enabled
tak_server_enabled_desc
tak_server_export_data_package_desc
tak_server_loading
tak_server_section
tak_server_test_card_title
tak_server_test_idle
tak_server_test_result_bytes
tak_server_test_result_unknown_error
tak_server_test_results
tak_server_test_run
tak_server_test_running
tak_team
tak_team_blue
tak_team_brown

View File

@@ -35,7 +35,7 @@ A step-by-step workflow for implementing a new feature in the Meshtastic-Android
```bash
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
```
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `app/proguard-rules.pro` and `desktop/proguard-rules.pro`, then verify release builds:
- If the feature adds a new reflection-heavy dependency, add keep rules to **both** `androidApp/proguard-rules.pro` and `desktopApp/proguard-rules.pro`, then verify release builds:
```bash
./gradlew assembleFdroidRelease :desktop:runRelease
./gradlew assembleFdroidRelease :desktopApp:runRelease
```

View File

@@ -52,10 +52,10 @@ Guidelines on managing Kotlin Multiplatform (KMP) source-sets, expected abstract
1. Ensure all new logic compiles against the KMP core (`jvm()`, `iosArm64()`, etc.).
2. Do not use platform-specific constructs in `commonMain` or you break the iOS/Desktop builds.
3. Test using `kmpSmokeCompile` to verify cross-platform compilation.
4. For desktop wiring, copy the pattern in `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
4. For desktop wiring, copy the pattern in `desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` and use `NoopStubs.kt` to temporarily mock missing platform implementations.
## Reference Anchors
- **Shared Okio I/O:** `core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportProfileUseCase.kt`
- **Desktop DI Stubs:** `desktop/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt`
- **Desktop DI Stubs:** `desktopApp/src/main/kotlin/org/meshtastic/desktop/stub/NoopStubs.kt`
- **Version Catalog:** `gradle/libs.versions.toml`
- **Convention Plugins:** `build-logic/convention/`

View File

@@ -7,7 +7,7 @@ This skill covers dependency injection (Koin Annotations 4.2.x) and JetBrains Na
### Guidelines
1. **Annotations First:** Use `@Module`, `@ComponentScan`, and `@KoinViewModel` annotations directly in `commonMain` shared modules to encapsulate dependency graphs per feature.
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/.../DesktopKoinModule.kt`.
2. **App Root Assembly:** Don't assume feature/core `@Module` classes are active automatically. Ensure they are included by the app root module (`@Module(includes = [...])`) in `androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktopApp/.../DesktopKoinModule.kt`.
3. **No Platform Bleed:** Don't put Android framework dependencies (`Context`, `Activity`, `Application`) into shared `commonMain` business logic. Inject interfaces instead.
4. **Resolution:** Resolve app-layer wrappers via `koinViewModel()` or injected bindings within Compose navigation graphs.
@@ -49,8 +49,8 @@ startKoin<AndroidKoinApp> {
- **Custom Backstack Mutation:** Do **not** mutate back navigation with custom stacks disconnected from the app backstack. Mutate `NavBackStack<NavKey>` directly with `add(...)` and `removeLastOrNull()`.
## Reference Anchors
- **App Startup / Koin Bootstrap:** `app/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
- **DI Bootstrap Object:** `app/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
- **DI App Wiring:** `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- **App Startup / Koin Bootstrap:** `androidApp/src/main/kotlin/org/meshtastic/app/MeshUtilApplication.kt`
- **DI Bootstrap Object:** `androidApp/src/main/kotlin/org/meshtastic/app/di/AndroidKoinApp.kt`
- **DI App Wiring:** `androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- **Shared Routes:** `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- **Desktop Nav Shell:** `desktop/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`
- **Desktop Nav Shell:** `desktopApp/src/main/kotlin/org/meshtastic/desktop/ui/DesktopMainScreen.kt`

View File

@@ -11,7 +11,7 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
| Directory | Description |
| :--- | :--- |
| `app/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `androidApp/` | Main application module. Contains `MainActivity`, Koin DI modules, and app-level logic. Uses package `org.meshtastic.app`. |
| `build-logic/` | Convention plugins for shared build configuration (e.g., `meshtastic.kmp.feature`, `meshtastic.kmp.library`, `meshtastic.kmp.jvm.android`, `meshtastic.koin`). |
| `config/` | Detekt static analysis rules (`config/detekt/detekt.yml`) and Spotless formatting config (`config/spotless/.editorconfig`). |
| `docs/` | Architecture docs and agent playbooks. See `docs/kmp-status.md` and `docs/roadmap.md` for current status. |
@@ -38,7 +38,7 @@ Module directory, namespacing conventions, environment setup, and troubleshootin
| `feature/` | Feature modules (e.g., `settings`, `map`, `messaging`, `node`, `intro`, `connections`, `firmware`, `wifi-provision`, `widget`). All are KMP except `widget`. Use `meshtastic.kmp.feature` convention plugin. |
| `feature/wifi-provision` | KMP WiFi provisioning via BLE (Nymea protocol). Uses `core:ble` Kable abstractions. |
| `feature/firmware` | Fully KMP firmware update system: Unified OTA (BLE + WiFi), native Nordic Secure DFU protocol (pure KMP), USB/UF2 updates, and `FirmwareRetriever` with manifest-based resolution. Desktop is a first-class target. |
| `desktop/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
| `desktopApp/` | Compose Desktop application. Thin host shell relying on feature modules for shared UI. Full Koin DI graph, TCP, Serial/USB, and BLE transports. Versioning via `config.properties` + `GitVersionValueSource`. |
## Namespacing
- **Standard:** Use the `org.meshtastic.*` namespace for all code.

View File

@@ -52,7 +52,7 @@ The standard SDD cycle for a new feature:
```text
1. /speckit.specify "Feature description here"
→ Creates specs/<NNN>-feature-name/spec.md
→ Creates specs/<YYYYMMDD-HHMMSS>-feature-name/spec.md
→ Auto-creates feature branch via git hook
2. /speckit.clarify
@@ -85,11 +85,11 @@ This runs: specify → (review gate) → plan → (review gate) → tasks → im
## File Structure
Spec Kit produces files under `specs/<NNN>-feature-name/`:
Spec Kit produces files under `specs/<YYYYMMDD-HHMMSS>-feature-name/`:
```
specs/
└── 001-feature-name/
└── 20260513-160000-feature-name/
├── spec.md # Feature specification (FRs, NFRs, SCs, user stories)
├── plan.md # Implementation plan (architecture, phases)
├── tasks.md # Dependency-ordered task list

View File

@@ -17,10 +17,32 @@ Run in a single invocation for routine changes to ensure code formatting, analys
> In KMP modules, the `test` task name is **ambiguous**. Gradle matches both `testAndroid` and
> `testAndroidHostTest` and refuses to run either, silently skipping KMP modules.
> `allTests` is the `KotlinTestReport` lifecycle task registered by the KMP plugin.
> Conversely, `allTests` does **not** cover pure-Android modules (`:app`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
> Conversely, `allTests` does **not** cover pure-Android modules (`:androidApp`, `:core:api`, etc.), which is why both `test` and `allTests` are needed.
*Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to `@Config(sdk = [34])` to avoid SDK 35 compatibility crashes.*
### SharedFlow + backgroundScope in `runTest`
When testing long-lived coroutines (e.g., `Flow.collect` loops launched in `backgroundScope`), **use `runTest(UnconfinedTestDispatcher())`** instead of plain `runTest`:
```kotlin
// ❌ BAD — SharedFlow emissions silently never reach collectors
@Test fun `inbound packet is forwarded`() = runTest {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion fails — collector never receives the emission
}
// ✅ GOOD — UnconfinedTestDispatcher eagerly dispatches subscriber resumptions
@Test fun `inbound packet is forwarded`() = runTest(UnconfinedTestDispatcher()) {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion passes — collector receives emission immediately
}
```
**Why:** `backgroundScope` uses `StandardTestDispatcher` by default, which does **not** eagerly dispatch `SharedFlow` subscriber resumptions. Even `advanceUntilIdle()` won't trigger delivery. `UnconfinedTestDispatcher()` fixes this by dispatching eagerly. This affects any test where a coroutine in `backgroundScope` collects from a `SharedFlow` or `MutableSharedFlow`.
## 2) Change-type verification matrix
- `docs-only` changes: Usually no Gradle run required, but run `spotlessCheck` if practical.

View File

@@ -30,7 +30,7 @@ Validate the implementation against its specification artifacts (`spec.md`, `pla
Run `{SCRIPT}` from repo root.
1. **Script succeeds** (on a feature branch): Parse JSON for FEATURE_DIR. Set `FEATURE_BRANCH = true`. Proceed to next step.
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/NNN-*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
2. **Script fails** (not on a feature branch): You MUST prompt for available features (Scan `specs/*/` to get available features). Use the **AskUserQuestion tool** to let the user select. **Do NOT guess or auto-select a change. Always let the user choose.**
Derive absolute paths:

View File

@@ -1,6 +1,6 @@
{
"ai": "opencode",
"branch_numbering": "sequential",
"branch_numbering": "timestamp",
"context_file": "AGENTS.md",
"here": true,
"integration": "opencode",

View File

@@ -1,19 +1,17 @@
<!--
SYNC IMPACT REPORT
==================
Version change: 1.1.1 → 1.2.0
Version change: 1.2.0 → 1.3.0
Modified principles:
- V. Design Standards Compliance (expanded to require cross-platform behavior spec
from meshtastic/design/features/ for multi-platform features)
Added sections: None.
- VI. Verify Before Push → renumbered to VII.
Added sections:
- VI. Documentation Freshness (new principle requiring last_updated frontmatter,
blocking staleness check, link validation, coverage checks, freshness warnings)
Removed sections: None.
Templates requiring updates:
.specify/templates/spec-template.md — Added Cross-Platform Spec metadata field and
cross-platform check guidance in Summary comment.
✅ .specify/templates/plan-template.md — Constitution Check V updated to require upstream
spec link for cross-platform features.
✅ .specify/templates/checklist-template.md — CHK005 updated to include cross-platform spec check.
Follow-up TODOs: None.
- .specify/templates/checklist-template.md — Add CHK006 for documentation freshness
- .specify/templates/plan-template.md — Constitution Check VII for docs freshness
Follow-up TODOs: Update AGENTS.md with docs governance reference.
-->
# Meshtastic Android (KMP) Constitution
@@ -90,7 +88,41 @@ All user-facing UI MUST conform to the Meshtastic Client Design Standards:
users have a predictable experience regardless of platform. The design standards are
maintained collaboratively across all Meshtastic client teams.
### VI. Verify Before Push
### VI. Documentation Freshness
In-app documentation MUST remain accurate and current as the codebase evolves.
Documentation changes propagate to **three consumers** — all three MUST be considered:
1. **In-app docs browser**`syncDocsToComposeResources` copies `docs/` into Compose
Resources at build time. Changes are bundled into the app automatically.
2. **Jekyll site** (GitHub Pages) — `docs/` is served directly. The `docs-deploy.yml`
workflow rebuilds on push to `main`.
3. **Docusaurus site** (meshtastic.org) — `scripts/sync-android-docs.js` transforms
`docs/` for the external site. Runs weekly via the `meshtastic/meshtastic` repo.
Governance rules:
- Every doc page MUST include a `last_updated` frontmatter field (YYYY-MM-DD).
Update this field whenever page content changes.
- PRs that modify user-facing UI source files MUST update the corresponding doc page(s)
or apply the `skip-docs-check` label with justification. The docs staleness check is a
**blocking** CI gate.
- Internal cross-references between doc pages and image paths MUST be validated; broken
links fail the `docs-governance` workflow.
- Every user-facing feature module MUST have corresponding documentation in `docs/user/`
or `docs/developer/`. Coverage is checked by `scripts/check-doc-coverage.js`.
- Pages older than 180 days without updates trigger an advisory freshness warning.
- New doc pages MUST be registered in `DocBundleLoader.kt` (in-app index), and added to
the `KNOWN_*_SLUGS` sets in `sync-android-docs.js` (Docusaurus link resolution).
Jekyll picks up new pages automatically via `_config.yml` scope-based defaults.
- Image references MUST use root-relative paths (`/assets/screenshots/filename.png`) so
they resolve correctly in both Jekyll and the in-app renderer. The sync script rewrites
these to Docusaurus paths automatically.
- Rationale: Documentation that drifts from the implementation misleads users, increases
support burden, and undermines the in-app help experience. Three distinct consumers
means a single source change must be verified across all delivery channels.
### VII. Verify Before Push
Local verification MUST complete successfully before any `git push`:
@@ -162,7 +194,7 @@ This constitution supersedes all other practices, coding guidelines, and agent i
- PATCH: Clarifications, wording fixes, or non-semantic refinements.
**Compliance Review**: Every implementation plan and PR description MUST include a
Constitution Check confirming all six principles were evaluated. Complexity violations
Constitution Check confirming all seven principles were evaluated. Complexity violations
require explicit justification in the Complexity Tracking table of the plan document.
**Version**: 1.2.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-05-12
**Version**: 1.3.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-05-13

View File

@@ -9,71 +9,71 @@ See [GitHub Releases](https://github.com/meshtastic/Meshtastic-Android/releases)
### Unreleased (not yet in any build)
* refactor: use immutable destNum in RadioConfigViewModel (#5436) by @James Rich in [`1976808a3`](https://github.com/meshtastic/Meshtastic-Android/commit/1976808a361942f2c358329807d3605b8898e36d)
* docs: update CHANGELOG.md (#5431) by @github-actions[bot] in [`57b0200b6`](https://github.com/meshtastic/Meshtastic-Android/commit/57b0200b6123e87f5037b12128411b69eee2ad23)
* Flatpak: Add Maven/Gradle mirror URLs (#5433) by @Austin in [`ada16f4f5`](https://github.com/meshtastic/Meshtastic-Android/commit/ada16f4f5f2e859624ad8588ff07cdbff945aac0)
* fix: update screenshots (#5435) by @James Rich in [`cf0a7cdab`](https://github.com/meshtastic/Meshtastic-Android/commit/cf0a7cdabff85e97accf4522709dfcb0fd5d4298)
* chore: clean up brownfield specs and migrate to timestamp naming (#5432) by @James Rich in [`73469b415`](https://github.com/meshtastic/Meshtastic-Android/commit/73469b415e742a6601790b9574e1cf8a4a43a52c)
* revert: Update retry settings in gradle-wrapper.properties (#5430) by @James Rich in [`ff9d6881c`](https://github.com/meshtastic/Meshtastic-Android/commit/ff9d6881c01b8e9ec7654179bfa559e93bea9974)
* build(flatpak): overhaul and automate flatpak offline manifest generation (#5529) by James Rich (@jamesarich) in [`99b5688b0`](https://github.com/meshtastic/Meshtastic-Android/commit/99b5688b00e5810467769ae57e6ee33ea3d3360c)
### Internal (v2.7.14-internal.90)
Changes since [`v2.7.14-open.8`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.14-open.8):
### Internal (v2.7.14-internal.95)
Changes since [`v2.7.14-open.9`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.14-open.9):
* docs: update CHANGELOG.md (#5427) by @github-actions[bot] in [`1b902ba07`](https://github.com/meshtastic/Meshtastic-Android/commit/1b902ba07c7a6f5b41bba9f4cb439e7de0925e4c)
* chore(deps): update gradle to v9.5.1 (#5429) by @renovate[bot] in [`010ff358d`](https://github.com/meshtastic/Meshtastic-Android/commit/010ff358d0cf5c586b557af186ffdd1ef719be81)
* fix: clarify position precision as ± radius (#5428) by @James Rich in [`dcb147163`](https://github.com/meshtastic/Meshtastic-Android/commit/dcb147163c3a5c8dada885ee6e84790fcc24f651)
* fix(desktop): keep Vico package to prevent bytecode verification errors (#5424) by @James Rich in [`ab4589c06`](https://github.com/meshtastic/Meshtastic-Android/commit/ab4589c06a849743268f926bbe52723c833add3d)
* build: conditionalize JitPack repository for desktop-only builds (#5426) by @James Rich in [`fcf0b5074`](https://github.com/meshtastic/Meshtastic-Android/commit/fcf0b507461796ff2e84265de728283ed5537073)
* docs: update CHANGELOG.md (#5422) by @github-actions[bot] in [`3bfc66eb2`](https://github.com/meshtastic/Meshtastic-Android/commit/3bfc66eb29c438c3f2d68a9225eba9fb3d2c2e25)
* fix: enhance uniqueness check for Flatpak source files in release workflow. (#5423) by @Austin in [`a2dd4d18a`](https://github.com/meshtastic/Meshtastic-Android/commit/a2dd4d18aab2ed1a9000e4ac3ed53260658a4734)
* chore(deps): update core/proto/src/main/proto digest to 1c62540 (#5421) by @renovate[bot] in [`05b1ea918`](https://github.com/meshtastic/Meshtastic-Android/commit/05b1ea918ef3f14b48f1777402e645df900dd337)
* docs: update CHANGELOG.md (#5420) by @github-actions[bot] in [`827535ad4`](https://github.com/meshtastic/Meshtastic-Android/commit/827535ad45778e1eb5b369be70c955b300678a37)
* fix: clamp future lastHeard timestamps to current time on ingestion (#5418) by @James Rich in [`0f2b1c064`](https://github.com/meshtastic/Meshtastic-Android/commit/0f2b1c064aa9b77685ac8a7d05fe81af89655027)
* fix(ci): resolve flatpak source merge race condition (#5419) by @James Rich in [`6f42f0788`](https://github.com/meshtastic/Meshtastic-Android/commit/6f42f078878258b334ae5b234c08caf76d2fa817)
* docs: update CHANGELOG.md (#5417) by @github-actions[bot] in [`b96515e7d`](https://github.com/meshtastic/Meshtastic-Android/commit/b96515e7d048a0850f29cc5381326fd796a07e2c)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5416) by @James Rich in [`09a0d123b`](https://github.com/meshtastic/Meshtastic-Android/commit/09a0d123bed0e79415a8297c5cba92bab636d79e)
* chore(deps): update core/proto/src/main/proto digest to 1c62540 (#5413) by @renovate[bot] in [`56077db6d`](https://github.com/meshtastic/Meshtastic-Android/commit/56077db6d93fd76ca2fa585f49eb63d2e91ad400)
* chore(deps): update actions/checkout action to v6 (#5414) by @renovate[bot] in [`d4ff10ef3`](https://github.com/meshtastic/Meshtastic-Android/commit/d4ff10ef3b1551637e016e1f4bee4e5c2a98d645)
* Actions: fix Flatpak source file patterns for jq (#5415) by @Austin in [`1c418a57e`](https://github.com/meshtastic/Meshtastic-Android/commit/1c418a57ea09b0c6af6fde22e9d0ddee16b53e7a)
* docs: update CHANGELOG.md (#5412) by @github-actions[bot] in [`bce8bba90`](https://github.com/meshtastic/Meshtastic-Android/commit/bce8bba90a74bdc64353e28b5abeb11474017b74)
* feat: add Compose Preview Screenshot Testing infrastructure (#5410) by @James Rich in [`85c840de3`](https://github.com/meshtastic/Meshtastic-Android/commit/85c840de32096a00aa7fac42fca0d3a5417d3961)
* fix(ci): prevent literal 'false' task name in desktop release build (#5411) by @James Rich in [`7202994ab`](https://github.com/meshtastic/Meshtastic-Android/commit/7202994abee99dc16a54f38d34a7607c2a573663)
* docs: update CHANGELOG.md (#5409) by @github-actions[bot] in [`1373bd330`](https://github.com/meshtastic/Meshtastic-Android/commit/1373bd330b4829ac29d37b0e1e088d21b66dc037)
* feat(api): add hasAnyEntries method to local data sources and improve… (#5406) by @James Rich in [`10c5b5db2`](https://github.com/meshtastic/Meshtastic-Android/commit/10c5b5db2e5b23c52a3a5689205bfc42285b7b0a)
* Update notification intents and deep link URI format (#5408) by @James Rich in [`82135df86`](https://github.com/meshtastic/Meshtastic-Android/commit/82135df865c4595d3ee509ac3535b86e343874b6)
* docs: update CHANGELOG.md (#5399) by @github-actions[bot] in [`fd0e2c090`](https://github.com/meshtastic/Meshtastic-Android/commit/fd0e2c0904d21df2a962bd18ee138afabf869e98)
* chore(deps): update core/proto/src/main/proto digest to 1c62540 (#5404) by @renovate[bot] in [`d84781848`](https://github.com/meshtastic/Meshtastic-Android/commit/d847818487787a854b59e06e29b7831aef45b514)
* chore(deps): update ruby to v4.0.4 (#5407) by @renovate[bot] in [`84cc8ad0d`](https://github.com/meshtastic/Meshtastic-Android/commit/84cc8ad0debf620c614617cf32fcce971d48a87d)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5400) by @James Rich in [`313dc1c2f`](https://github.com/meshtastic/Meshtastic-Android/commit/313dc1c2f7734284a2c6822e2a62dc10c597bfb1)
* fix: graceful HTTP error handling in scheduled-updates workflow (#5405) by @Copilot in [`b33738728`](https://github.com/meshtastic/Meshtastic-Android/commit/b337387287c8c4e0e3ec3c48cbf19ca39de1ea36)
* feat(desktop): fix mac notifications, new desktop icons (#5403) by @James Rich in [`c77e03c5c`](https://github.com/meshtastic/Meshtastic-Android/commit/c77e03c5c1b49d9ce6684042dca684fca68a1e99)
* Brownfield gap remediation: 28 tasks + intro commonMain migration (#5401) by @James Rich in [`95c3bc0bc`](https://github.com/meshtastic/Meshtastic-Android/commit/95c3bc0bce4a0d30228d9c411c76cedb818afc91)
* ci: disable Flatpak source generation for PR and merge queue checks (#5402) by @James Rich in [`6eacee626`](https://github.com/meshtastic/Meshtastic-Android/commit/6eacee626b42133c742d8ca935590d7a4c94e3ee)
* chore(deps): update core/proto/src/main/proto digest to b302d92 (#5398) by @renovate[bot] in [`f89fdaf99`](https://github.com/meshtastic/Meshtastic-Android/commit/f89fdaf99dccb46517458e7fecf840bcc9c86f44)
* docs: update CHANGELOG.md (#5387) by @github-actions[bot] in [`8c0ced2ad`](https://github.com/meshtastic/Meshtastic-Android/commit/8c0ced2adcbd7a93aba109aeaab7405df57b03dd)
* Gradle: Add flatpakGradleGenerator task (#5369) by @Austin in [`484518195`](https://github.com/meshtastic/Meshtastic-Android/commit/484518195e7dbf980de6b951410f23ebac11c237)
* chore(deps): update core/proto/src/main/proto digest to 03eb534 (#5397) by @renovate[bot] in [`2d5bcc1e5`](https://github.com/meshtastic/Meshtastic-Android/commit/2d5bcc1e5093f21dbd73befe6dcfad4dac6b1cb6)
* Release: Add MacOS ReleaseUberJar target (#5389) by @Austin in [`234a78a56`](https://github.com/meshtastic/Meshtastic-Android/commit/234a78a5640eb3979172b8523d49a1b35750e49c)
* chore(deps): update vico to v3.2.0-next.4 (#5395) by @renovate[bot] in [`39c765c63`](https://github.com/meshtastic/Meshtastic-Android/commit/39c765c63e35afafb1b788d4ca3150b35c814348)
* chore(deps): update fastlane to v2.234.0 (#5396) by @renovate[bot] in [`880e98abf`](https://github.com/meshtastic/Meshtastic-Android/commit/880e98abf05300a01f678bdee472b22b52d78058)
* chore(deps): update org.jetbrains.kotlinx:kotlinx-datetime to v0.8.0-0.6.x-compat (#5382) by @renovate[bot] in [`879e147ef`](https://github.com/meshtastic/Meshtastic-Android/commit/879e147ef0a018b9d7b5214b70ec8e36a80e3d11)
* chore(deps): update kotlinx.coroutines.android to v1.11.0 (#5393) by @renovate[bot] in [`78f9a8940`](https://github.com/meshtastic/Meshtastic-Android/commit/78f9a8940cdf4cbd83d78796ccc3a8aef4c4e5ac)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5383) by @James Rich in [`443de4a34`](https://github.com/meshtastic/Meshtastic-Android/commit/443de4a34d54d33a8e7d7ec46606b5fa70921a36)
* docs: Update specs and governance for Android M3 accessibility (#5392) by @James Rich in [`0d195ead5`](https://github.com/meshtastic/Meshtastic-Android/commit/0d195ead53d5f88f73e581a7ee005f65bdceccfa)
* docs(specs): add feature specs for discovery, node-list-layout, and app-docs (#5388) by @James Rich in [`c0d95d6ac`](https://github.com/meshtastic/Meshtastic-Android/commit/c0d95d6ac4196fcbc705f2d3f174c7d9c46a77b2)
* chore(deps): update com.google.firebase:firebase-bom to v34.13.0 (#5385) by @renovate[bot] in [`934e687bc`](https://github.com/meshtastic/Meshtastic-Android/commit/934e687bc5c14b605797d2ca8f78c231fdfd5210)
* docs: comprehensive copilot instructions & spec-kit setup (#5386) by @James Rich in [`031f25ced`](https://github.com/meshtastic/Meshtastic-Android/commit/031f25cedaf994b09bff014245ee8ab448aafa2a)
* docs: update CHANGELOG.md (#5381) by @github-actions[bot] in [`aeff0a6ce`](https://github.com/meshtastic/Meshtastic-Android/commit/aeff0a6ce1b3a61669d55029ee04863c94711bc6)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5378) by @James Rich in [`924f8a507`](https://github.com/meshtastic/Meshtastic-Android/commit/924f8a507b257829ec15ac446ff31c26f1ecb6ac)
* chore(deps): update aboutlibraries to v14.2.0 (#5380) by @renovate[bot] in [`d68aada1b`](https://github.com/meshtastic/Meshtastic-Android/commit/d68aada1ba9142efd088d9e205341322e267936d)
* docs: update CHANGELOG.md (#5377) by @github-actions[bot] in [`3dd985d7d`](https://github.com/meshtastic/Meshtastic-Android/commit/3dd985d7dfaa53891eca4dcdc90f0330044d5106)
* refactor(firmware): replace PlatformBackHandler with NavigationBackHandler (#5376) by @James Rich in [`213c38051`](https://github.com/meshtastic/Meshtastic-Android/commit/213c380514438e1e208f0a0439437d6e62b041f1)
* docs: update CHANGELOG.md (#5374) by @github-actions[bot] in [`8d621bc6f`](https://github.com/meshtastic/Meshtastic-Android/commit/8d621bc6f48e167c3bdda1add22e2938dadf0bd5)
* chore(deps): update paging to v3.5.0 (#5375) by @renovate[bot] in [`0b75fd414`](https://github.com/meshtastic/Meshtastic-Android/commit/0b75fd4140ed5643806fc8f1a6eb8c3dfaaeb049)
* chore(deps): update compose-multiplatform to v1.11.1 (#5371) by @renovate[bot] in [`d28d684d0`](https://github.com/meshtastic/Meshtastic-Android/commit/d28d684d054fa1d82ec84ed0c2ecb3ddf4705766)
* chore(deps): update androidx.camera.viewfinder:viewfinder-compose to v1.6.1 (#5370) by @renovate[bot] in [`2f2a71587`](https://github.com/meshtastic/Meshtastic-Android/commit/2f2a71587dd5ff36da025e5761159ff182371b81)
* chore(deps): update vico to v3.2.0-next.5 (#5470) by renovate[bot] (@renovate[bot]) in [`4bacff81c`](https://github.com/meshtastic/Meshtastic-Android/commit/4bacff81c5abad536b40f7370ae5d302b74a11fe)
* chore(deps): update spotless to v8.5.1 (#5468) by renovate[bot] (@renovate[bot]) in [`c91219d8b`](https://github.com/meshtastic/Meshtastic-Android/commit/c91219d8b0411975c059c7a5ae6eb370386954f6)
* chore(deps): update wire to v6.4.0 (#5466) by renovate[bot] (@renovate[bot]) in [`f0e12695b`](https://github.com/meshtastic/Meshtastic-Android/commit/f0e12695bbf3df16b043476ff98856b727d6a2f7)
* fix(database): make withDb retry logic resilient to varying close messages (#5474) by James Rich (@jamesarich) in [`057d5bb77`](https://github.com/meshtastic/Meshtastic-Android/commit/057d5bb778a72cf4359abc7fae904d587cdc90a2)
* ci: remove desktop build job from reusable-check to cut macOS runner costs (#5475) by James Rich (@jamesarich) in [`d24fc9ac9`](https://github.com/meshtastic/Meshtastic-Android/commit/d24fc9ac9245b69718e2cb2a6c3a872e36d8c28c)
* refactor(build): rename entry modules and remove DESKTOP_ONLY mode (#5476) by James Rich (@jamesarich) in [`f4b6b02ac`](https://github.com/meshtastic/Meshtastic-Android/commit/f4b6b02acecdd8855408db7d92757f2495fbc11f)
* fix(settings): add input validation for BLE PIN, LoRa modem, and ambient lighting (#5477) by James Rich (@jamesarich) in [`1dd47bc09`](https://github.com/meshtastic/Meshtastic-Android/commit/1dd47bc09032fe0972af7eed6a7554e61be7a02b)
* chore(deps): update markdownrenderer to v0.41.0 (#5471) by renovate[bot] (@renovate[bot]) in [`f6587a123`](https://github.com/meshtastic/Meshtastic-Android/commit/f6587a12364cc490b5872ed7bc77cfeed9e414a6)
* fix(nav): remote admin nodenum + Nav3 consolidation and improvements (#5478) by James Rich (@jamesarich) in [`df4f10c4d`](https://github.com/meshtastic/Meshtastic-Android/commit/df4f10c4d64a273688aa9dbe5284ac3737756310)
* chore(deps): update core/proto/src/main/proto digest to 59cb394 (#5480) by renovate[bot] (@renovate[bot]) in [`72436e70b`](https://github.com/meshtastic/Meshtastic-Android/commit/72436e70bc1db9373581bc53e8cc62548e596744)
* feat: adopt Material 3 Expressive design system (M3-native APIs only) (#5479) by James Rich (@jamesarich) in [`f5128798a`](https://github.com/meshtastic/Meshtastic-Android/commit/f5128798a808219a28e9ee0916c079edcb203744)
* feat(docs): In-app documentation browser with Jekyll site and Docusaurus sync (#5445) by James Rich (@jamesarich) in [`fc0df1a79`](https://github.com/meshtastic/Meshtastic-Android/commit/fc0df1a79ad1d8ce355803f5ac6eb4a1f2ec78c3)
* chore(deps): update actions/checkout action to v6 (#5481) by renovate[bot] (@renovate[bot]) in [`2e484e219`](https://github.com/meshtastic/Meshtastic-Android/commit/2e484e219c132b89597ffa939e1801765a5d5352)
* chore(deps): update actions/deploy-pages action to v5 (#5482) by renovate[bot] (@renovate[bot]) in [`d7cccd0db`](https://github.com/meshtastic/Meshtastic-Android/commit/d7cccd0dba442d8c8892a4aac8124f565563434a)
* chore(deps): update actions/github-script action to v9 (#5483) by renovate[bot] (@renovate[bot]) in [`f8a5f894a`](https://github.com/meshtastic/Meshtastic-Android/commit/f8a5f894a86b6145b86143888f7ea1fe3d9ccbb6)
* chore(deps): update io.nlopez.compose.rules:detekt to v0.5.9 (#5485) by renovate[bot] (@renovate[bot]) in [`4eb7ed8fe`](https://github.com/meshtastic/Meshtastic-Android/commit/4eb7ed8fe0853850a8056524403d4da3d8c36de3)
* chore(deps): update actions/setup-node action to v6 (#5486) by renovate[bot] (@renovate[bot]) in [`733cb92ae`](https://github.com/meshtastic/Meshtastic-Android/commit/733cb92aecb51deab11edb2e9c3e198f9579304c)
* chore(deps): update gradle/actions action to v6 (#5488) by renovate[bot] (@renovate[bot]) in [`21993b6cc`](https://github.com/meshtastic/Meshtastic-Android/commit/21993b6cc7eba8776262c3805942ddc460ab0c5b)
* chore(deps): update actions/upload-pages-artifact action to v5 (#5487) by renovate[bot] (@renovate[bot]) in [`4afa1a032`](https://github.com/meshtastic/Meshtastic-Android/commit/4afa1a032fc465464e7408ef36d0505876ce9d61)
* chore(deps): update actions/setup-java action to v5 (#5484) by renovate[bot] (@renovate[bot]) in [`bbdc4a300`](https://github.com/meshtastic/Meshtastic-Android/commit/bbdc4a300406ae417ad7a70fb4cdd826debb5cb3)
* docs: comprehensive accuracy audit and CI fix (#5489) by James Rich (@jamesarich) in [`ece771edb`](https://github.com/meshtastic/Meshtastic-Android/commit/ece771edb06f0110050baac322a093214b89f6c8)
* fix(ci): disable configuration cache for Dokka build (#5492) by James Rich (@jamesarich) in [`fe2cbae87`](https://github.com/meshtastic/Meshtastic-Android/commit/fe2cbae8756c4af9f1daacd842ab0fde097ffab6)
* chore(deps): update compose-multiplatform to v1.3.0-beta01 (#5490) by renovate[bot] (@renovate[bot]) in [`3bfaa466a`](https://github.com/meshtastic/Meshtastic-Android/commit/3bfaa466afc01fe03cc69ba7437610441ba8bcf7)
* chore(deps): update node to v24 (#5491) by renovate[bot] (@renovate[bot]) in [`cdf57ced8`](https://github.com/meshtastic/Meshtastic-Android/commit/cdf57ced8e9d6a6ce66a88f10da304a41b0ab4c1)
* chore(deps): update peter-evans/create-pull-request action to v8 (#5493) by renovate[bot] (@renovate[bot]) in [`a141df437`](https://github.com/meshtastic/Meshtastic-Android/commit/a141df437298b039086e9084ec234f925ffa09b2)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5465) by James Rich (@jamesarich) in [`83bb1a31f`](https://github.com/meshtastic/Meshtastic-Android/commit/83bb1a31f75bfd2717428f2fed287eb1e830302f)
* docs: update CHANGELOG.md (#5473) by github-actions[bot] (@github-actions[bot]) in [`3121ea09a`](https://github.com/meshtastic/Meshtastic-Android/commit/3121ea09a3abb43713f0a090dac5147b6ae76f84)
* fix(docs): use locale subdirectory inside files/ instead of qualifier (#5494) by James Rich (@jamesarich) in [`418861d35`](https://github.com/meshtastic/Meshtastic-Android/commit/418861d356f1edf5ac0c4b00f3bfc761ad34e609)
* fix(ci): unblock Dokka documentation generation (#5496) by James Rich (@jamesarich) in [`228765a15`](https://github.com/meshtastic/Meshtastic-Android/commit/228765a159b6f1498566d3fe6005f91b470761b4)
* chore(deps): update compose-multiplatform to v1.11.2 (#5497) by renovate[bot] (@renovate[bot]) in [`619897de8`](https://github.com/meshtastic/Meshtastic-Android/commit/619897de85616eb9bebedf850970abd24571f705)
* docs: update CHANGELOG.md (#5495) by github-actions[bot] (@github-actions[bot]) in [`51d2c7b15`](https://github.com/meshtastic/Meshtastic-Android/commit/51d2c7b15cf1b3520cfbef13b8dbcff0b72748e4)
* fix(ci): exclude pre-release tags from docs-release workflow (#5499) by James Rich (@jamesarich) in [`92cfbaee9`](https://github.com/meshtastic/Meshtastic-Android/commit/92cfbaee9b8819804571ae15fd0815b8ab785bce)
* docs: move English sources into docs/en/ locale folder (#5501) by James Rich (@jamesarich) in [`11bc37c96`](https://github.com/meshtastic/Meshtastic-Android/commit/11bc37c96878cd1d9a822c1dad658f43c9be6604)
* chore(deps): update org.junit.vintage:junit-vintage-engine to v6.1.0 (#5503) by renovate[bot] (@renovate[bot]) in [`ae127a1e1`](https://github.com/meshtastic/Meshtastic-Android/commit/ae127a1e15bdbbae6395a4c21da6440297cdb13b)
* chore(deps): update org.junit.platform:junit-platform-launcher to v6.1.0 (#5502) by renovate[bot] (@renovate[bot]) in [`2b7621221`](https://github.com/meshtastic/Meshtastic-Android/commit/2b76212217122604eeb14ba02ea45d93809773a0)
* chore(deps): update room to v3.0.0-alpha05 (#5498) by renovate[bot] (@renovate[bot]) in [`59499e827`](https://github.com/meshtastic/Meshtastic-Android/commit/59499e82723bdef4f1116ae5996814d59ab85595)
* docs: update CHANGELOG.md (#5500) by github-actions[bot] (@github-actions[bot]) in [`6c51c1ac1`](https://github.com/meshtastic/Meshtastic-Android/commit/6c51c1ac18cd61f3ab381335ecada8c9d614b7cb)
* fix(ci): use GitHub compare API for changelog author resolution (#5504) by James Rich (@jamesarich) in [`4ff6f23c5`](https://github.com/meshtastic/Meshtastic-Android/commit/4ff6f23c5e5d074f319736a74e563f28bf65c127)
* docs: update CHANGELOG.md (#5505) by github-actions[bot] (@github-actions[bot]) in [`f38536ae6`](https://github.com/meshtastic/Meshtastic-Android/commit/f38536ae61c8eaa99bcb315b4953bbfd90b7979c)
* fix(database): update @Relation annotations for Room 3.0.0-alpha05 (#5507) by James Rich (@jamesarich) in [`01d32e15c`](https://github.com/meshtastic/Meshtastic-Android/commit/01d32e15c00c952ca5933fb3d29c08d3a3989c59)
* chore(deps): update plugin flatpak-gradle-generator to v1.8.0 (#5506) by renovate[bot] (@renovate[bot]) in [`9260033e0`](https://github.com/meshtastic/Meshtastic-Android/commit/9260033e04f604a8f15bb3efce611bedc695d40b)
* docs: update CHANGELOG.md (#5508) by github-actions[bot] (@github-actions[bot]) in [`6fb3776e0`](https://github.com/meshtastic/Meshtastic-Android/commit/6fb3776e0c2588392111fb4da89780f8bf597eb5)
* build: reorder Maven repos to reduce flatpak generator 404s (#5509) by James Rich (@jamesarich) in [`930dee09e`](https://github.com/meshtastic/Meshtastic-Android/commit/930dee09e5b3f23d02d0dc5aae3eeea61974bada)
* chore(deps): migrate takpacket-sdk to Maven Central coordinates (#5512) by James Rich (@jamesarich) in [`5c3b22584`](https://github.com/meshtastic/Meshtastic-Android/commit/5c3b22584e4d5d71c6813842033bd2ae17b0041b)
* ci: reduce flatpak source generation thrash (#5513) by James Rich (@jamesarich) in [`3f64b5a0e`](https://github.com/meshtastic/Meshtastic-Android/commit/3f64b5a0ecf7cc9ed6e81068ab3067033a7f74f4)
* chore: Scheduled updates (Firmware, Hardware, Translations, Graphs) (#5511) by James Rich (@jamesarich) in [`1dcb7b9d1`](https://github.com/meshtastic/Meshtastic-Android/commit/1dcb7b9d1a29fcdfad214fe74360130c597ee989)
* docs: update CHANGELOG.md (#5510) by github-actions[bot] (@github-actions[bot]) in [`0803dd3ae`](https://github.com/meshtastic/Meshtastic-Android/commit/0803dd3ae8d6970ae8218dcfd6d0bb9b63866da9)
* fix: prevent node details hang when device hardware API is unreachable (#5514) by James Rich (@jamesarich) in [`877909fa9`](https://github.com/meshtastic/Meshtastic-Android/commit/877909fa94b55758be9dd91e2bd11e160b3822ed)
* chore(deps): update ruby to v4.0.5 (#5515) by renovate[bot] (@renovate[bot]) in [`d37e08fcc`](https://github.com/meshtastic/Meshtastic-Android/commit/d37e08fccdabd3f673e3eccf90ab1538f02fe867)
* docs: update CHANGELOG.md (#5516) by github-actions[bot] (@github-actions[bot]) in [`f5e5fb6da`](https://github.com/meshtastic/Meshtastic-Android/commit/f5e5fb6da63ee85e64dbbe19a519a5a3deccd408)
* fix(ci): exclude kotlinNativeBundleConfiguration from flatpak source generation (#5517) by James Rich (@jamesarich) in [`e13a4359e`](https://github.com/meshtastic/Meshtastic-Android/commit/e13a4359e188bbf02087cdb6253ea2b70c8200ed)
* docs: update CHANGELOG.md (#5518) by github-actions[bot] (@github-actions[bot]) in [`54fafd225`](https://github.com/meshtastic/Meshtastic-Android/commit/54fafd225bddfee446cfea01dee6887e082acc79)
* fix(ci): downgrade Ruby to 4.0.4 for ubuntu-24.04 compatibility (#5519) by James Rich (@jamesarich) in [`199827755`](https://github.com/meshtastic/Meshtastic-Android/commit/1998277555d2d3ac9f01f8af9f6c4a0652c169ad)
* ci: unify GitHub Pages deployment (Dokka + docs site) (#5523) by James Rich (@jamesarich) in [`a6523acbb`](https://github.com/meshtastic/Meshtastic-Android/commit/a6523acbb88766c6a1401f13d4b23ab679bf3af7)
* chore(deps): update koin.plugin to v1.0.0 (#5524) by renovate[bot] (@renovate[bot]) in [`8de0a1f16`](https://github.com/meshtastic/Meshtastic-Android/commit/8de0a1f167e498ccef999d3917023e1f34640a8c)
* chore(flatpak): optimize CI source generation and clean up redundant tasks (#5525) by James Rich (@jamesarich) in [`7ae1b2681`](https://github.com/meshtastic/Meshtastic-Android/commit/7ae1b2681313da5a07217ea6e04a0c3dc4b79946)
* fix(docs): compile Jekyll docs site and add root redirect in CI (#5526) by James Rich (@jamesarich) in [`f94358551`](https://github.com/meshtastic/Meshtastic-Android/commit/f94358551c7e41a2ea07ca97185fa8331b19f09c)
* docs: update CHANGELOG.md (#5520) by github-actions[bot] (@github-actions[bot]) in [`95df50014`](https://github.com/meshtastic/Meshtastic-Android/commit/95df500142abd44f7b4d2d75ba28ab72f1fe77ac)
### Open Beta (v2.7.14-open.8)
### Open Beta (v2.7.14-open.9)
Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/releases/tag/v2.7.13):
#### 🏗️ Features
@@ -154,6 +154,12 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea
* fix(connections): improve BLE scan reliability and UI lifecycle by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5329
* feat: event firmware easter egg with ambient branding by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5354
* feat: align theme with Design Standards v1.3, remove contrast setting by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5355
* feat(desktop): fix mac notifications, new desktop icons by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5403
* Update notification intents and deep link URI format by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5408
* fix: clarify position precision as ± radius by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5428
* feat: TAK v2 protocol integration with zstd compression and full CoT type support by @thebentern in https://github.com/meshtastic/Meshtastic-Android/pull/5434
#### 🖥️ Desktop
* fix(desktop): keep Vico package to prevent bytecode verification errors by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5424
#### 🛠️ Fixes
* fix(strings): replace plurals by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4596
* fix: replace fdroid map_style_selection string by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4598
@@ -259,6 +265,9 @@ Changes since [`v2.7.13`](https://github.com/meshtastic/Meshtastic-Android/relea
* fix(data): default new-node notifications off for event firmware by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5323
* fix(network): resolve empty MQTT address and enforce TLS on default server by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5333
* fix(mqtt): harden TLS enforcement, add user CA trust, and improve error diagnostics by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5365
* fix: clamp future lastHeard timestamps to current time on ingestion by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5418
* revert: Update retry settings in gradle-wrapper.properties by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5430
* fix: update screenshots by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/5435
#### 📝 Other Changes
* refactor(ui): compose resources, domain layer by @jamesarich in https://github.com/meshtastic/Meshtastic-Android/pull/4628
* Add per-message transport method icons for new message format by @Kealper in https://github.com/meshtastic/Meshtastic-Android/pull/4643

View File

@@ -20,7 +20,7 @@ Thank you for your interest in contributing to Meshtastic-Android! We welcome co
- Add comments where necessary, especially for complex logic.
- Keep methods and classes focused and concise.
- **Strings:** Use localised strings via the **Compose Multiplatform Resource** library in `:core:resources`.
- Do **not** use the legacy `app/src/main/res/values/strings.xml`.
- Do **not** use the legacy `androidApp/src/main/res/values/strings.xml`.
- **Definition:** Add strings to `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **Usage:**
```kotlin

View File

@@ -1,7 +1,7 @@
# `:app`
# `:androidApp`
## Overview
The `:app` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell.
The `:androidApp` module is the entry point for the Meshtastic Android application. It orchestrates the various feature modules, manages global state, and provides the main UI shell.
## Key Components
@@ -9,7 +9,7 @@ The `:app` module is the entry point for the Meshtastic Android application. It
The single Activity of the application. It hosts the shared `MeshtasticNavDisplay` navigation shell and manages the root UI structure (Navigation Bar, Rail, etc.).
### 2. `MeshService`
The core background service that manages long-running communication with the mesh radio. While it is declared in the `:app` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
The core background service that manages long-running communication with the mesh radio. While it is declared in the `:androidApp` manifest for system visibility, its implementation resides in the `:core:service` module. It runs as a **Foreground Service** to ensure reliable communication even when the app is in the background.
### 3. Koin Application
`MeshUtilApplication` is the Koin entry point, providing the global dependency injection container.
@@ -24,34 +24,35 @@ The module primarily serves as a "glue" layer, connecting:
<!--region graph-->
```mermaid
graph TB
:app[app]:::android-application
:app -.-> :core:ble
:app -.-> :core:common
:app -.-> :core:data
:app -.-> :core:database
:app -.-> :core:datastore
:app -.-> :core:di
:app -.-> :core:domain
:app -.-> :core:model
:app -.-> :core:navigation
:app -.-> :core:network
:app -.-> :core:nfc
:app -.-> :core:prefs
:app -.-> :core:proto
:app -.-> :core:service
:app -.-> :core:resources
:app -.-> :core:ui
:app -.-> :core:barcode
:app -.-> :core:takserver
:app -.-> :feature:intro
:app -.-> :feature:messaging
:app -.-> :feature:connections
:app -.-> :feature:map
:app -.-> :feature:node
:app -.-> :feature:settings
:app -.-> :feature:firmware
:app -.-> :feature:wifi-provision
:app -.-> :feature:widget
:androidApp[androidApp]:::android-application
:androidApp -.-> :core:ble
:androidApp -.-> :core:common
:androidApp -.-> :core:data
:androidApp -.-> :core:database
:androidApp -.-> :core:datastore
:androidApp -.-> :core:di
:androidApp -.-> :core:domain
:androidApp -.-> :core:model
:androidApp -.-> :core:navigation
:androidApp -.-> :core:network
:androidApp -.-> :core:nfc
:androidApp -.-> :core:prefs
:androidApp -.-> :core:proto
:androidApp -.-> :core:service
:androidApp -.-> :core:resources
:androidApp -.-> :core:ui
:androidApp -.-> :core:barcode
:androidApp -.-> :core:takserver
:androidApp -.-> :feature:intro
:androidApp -.-> :feature:messaging
:androidApp -.-> :feature:connections
:androidApp -.-> :feature:map
:androidApp -.-> :feature:node
:androidApp -.-> :feature:settings
:androidApp -.-> :feature:docs
:androidApp -.-> :feature:firmware
:androidApp -.-> :feature:wifi-provision
:androidApp -.-> :feature:widget
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;

View File

@@ -116,7 +116,7 @@ configure<ApplicationExtension> {
}
// Disable ABI splits for bundle builds or when explicitly requested via Gradle property.
// Usage: ./gradlew :app:bundleGoogleRelease -Pmeshtastic.disableAbiSplits=true
// Usage: ./gradlew :androidApp:bundleGoogleRelease -Pmeshtastic.disableAbiSplits=true
val disableSplits = providers.gradleProperty("meshtastic.disableAbiSplits").map { it.toBoolean() }.getOrElse(false)
// Enable ABI splits to generate smaller APKs per architecture for F-Droid/IzzyOnDroid
@@ -221,6 +221,7 @@ dependencies {
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.docs)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)
@@ -277,6 +278,9 @@ dependencies {
googleImplementation(platform(libs.firebase.bom))
googleImplementation(libs.firebase.analytics)
googleImplementation(libs.firebase.crashlytics)
googleImplementation(libs.firebase.ai)
googleImplementation(libs.firebase.ai.ondevice)
googleImplementation(libs.mlkit.translate)
fdroidImplementation(libs.osmdroid.android)
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }

View File

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (c) 2025-2026 Meshtastic LLC
~
~ This program is free software: you can redistribute it and/or modify
~ it under the terms of the GNU General Public License as published by
~ the Free Software Foundation, either version 3 of the License, or
~ (at your option) any later version.
~
~ This program is distributed in the hope that it will be useful,
~ but WITHOUT ANY WARRANTY; without even the implied warranty of
~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
~ GNU General Public License for more details.
~
~ You should have received a copy of the GNU General Public License
~ along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!--
Required for writing TAK route data packages to ATAK's auto-import directory.
Only declared for the F-Droid flavor — the Google Play flavor uses scoped
storage (SAF / app-scoped cache) so this permission is not needed there
and would violate Play policy.
-->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
</manifest>

View File

@@ -0,0 +1,32 @@
/*
* 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.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
@Module
class FdroidAiModule {
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
@Single fun docTranslationService(): DocTranslationService = NoOpDocTranslator()
}

View File

@@ -18,5 +18,5 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
@Module(includes = [FDroidNetworkModule::class])
@Module(includes = [FDroidNetworkModule::class, FdroidAiModule::class])
class FlavorModule

View File

@@ -34,11 +34,13 @@ import androidx.compose.material3.AlertDialogDefaults
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
@@ -723,69 +725,71 @@ private fun FdroidMainMapFilterDropdown(
mapFilterState: MapFilterState,
mapViewModel: MapViewModel,
) {
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier.background(MaterialTheme.colorScheme.surface),
) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
HorizontalDivider()
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.only_favorites), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleOnlyFavorites() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_waypoints), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
)
DropdownMenuItem(
text = {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = null,
modifier = Modifier.padding(end = 8.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Text(text = stringResource(Res.string.show_precision_circle), modifier = Modifier.weight(1f))
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
modifier = Modifier.padding(start = 8.dp),
)
}
},
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
}
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)

View File

@@ -0,0 +1,470 @@
/*
* 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.ai
import co.touchlab.kermit.Logger
import com.google.firebase.Firebase
import com.google.firebase.ai.Chat
import com.google.firebase.ai.DownloadStatus
import com.google.firebase.ai.InferenceMode
import com.google.firebase.ai.OnDeviceConfig
import com.google.firebase.ai.OnDeviceModelStatus
import com.google.firebase.ai.ai
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.PublicPreviewAPI
import com.google.firebase.ai.type.Tool
import com.google.firebase.ai.type.content
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.data.DocBundleLoader
import org.meshtastic.feature.docs.data.KeywordSearchEngine
import org.meshtastic.feature.docs.model.AIDocAssistantResult
import org.meshtastic.feature.docs.model.DocPage
import org.meshtastic.feature.docs.model.DocsAiError
/**
* Gemini on-device AI assistant for the Google flavor.
*
* Uses Firebase AI Logic hybrid SDK with [InferenceMode.ONLY_ON_DEVICE] for fast first-message answers, and a cloud
* model with URL context grounding for follow-up conversation. Supported on Pixel 9+, Samsung Galaxy S25/S26, OnePlus
* 13/15, and other devices with AICore.
*
* Context strategy: extracts only the **most relevant paragraphs** from each page (those containing query terms),
* strips markdown formatting to maximize information density, and fits within the on-device token budget (~4K tokens).
* This ensures fast, offline-capable answers even without cloud fallback.
*
* @see <a href="https://firebase.google.com/docs/ai-logic/hybrid/android/get-started">Firebase AI Logic Hybrid</a>
*/
@OptIn(PublicPreviewAPI::class)
class GeminiNanoDocAssistant(private val searchEngine: KeywordSearchEngine, private val bundleLoader: DocBundleLoader) :
AIDocAssistant {
/** On-device model for fast first-message answers grounded in bundled docs. */
private val onDeviceModel by lazy {
Firebase.ai(backend = GenerativeBackend.googleAI())
.generativeModel(
modelName = MODEL_NAME,
systemInstruction = content { text(SYSTEM_INSTRUCTION) },
onDeviceConfig = OnDeviceConfig(mode = InferenceMode.ONLY_ON_DEVICE),
)
}
/** Cloud model with URL context — fetches only from meshtastic.org and github.com/meshtastic. */
private val groundedModel by lazy {
Firebase.ai(backend = GenerativeBackend.googleAI())
.generativeModel(
modelName = MODEL_NAME,
systemInstruction = content { text(SYSTEM_INSTRUCTION) },
tools = listOf(Tool.urlContext()),
)
}
/** Active multi-turn chat session. Maintains conversation history across messages. */
private var chatSession: Chat? = null
private var messageCount = 0
override suspend fun isSupported(): Boolean = try {
// Always supported on Google flavor — cloud model with Google Search grounding is always available.
// On-device model provides faster/offline answers when available; check status to trigger download.
val ext = onDeviceModel.onDeviceExtension
val status = ext?.checkStatus()
Logger.d(tag = TAG) { "On-device model status: $status" }
when (status) {
OnDeviceModelStatus.AVAILABLE -> true
OnDeviceModelStatus.DOWNLOADING -> true
OnDeviceModelStatus.DOWNLOADABLE -> {
Logger.i(tag = TAG) { "Model downloadable — requesting download" }
ext.download().collect { downloadStatus ->
when (downloadStatus) {
is DownloadStatus.DownloadStarted ->
Logger.d(tag = TAG) { "Download started: ${downloadStatus.bytesToDownload} bytes" }
is DownloadStatus.DownloadInProgress ->
Logger.d(tag = TAG) {
"Download progress: ${downloadStatus.totalBytesDownloaded} bytes"
}
is DownloadStatus.DownloadCompleted -> Logger.i(tag = TAG) { "Model download completed" }
is DownloadStatus.DownloadFailed ->
Logger.w(tag = TAG) { "Model download failed: $downloadStatus" }
}
}
true
}
else -> true // Cloud grounded model is always available even without on-device support.
}
} catch (e: Exception) {
Logger.w(tag = TAG) { "isSupported() check failed, using cloud only: ${e.message}" }
true // Cloud grounded model is always available.
}
@Suppress("TooGenericExceptionCaught")
override suspend fun answer(question: String, currentPageId: String?): AIDocAssistantResult = try {
val bundle = bundleLoader.load()
val queryTerms = extractQueryTerms(question)
// Load all page content for full-text search ranking.
val allContent = bundle.pages.associateWith { page -> bundleLoader.readPage(page.id)?.markdown.orEmpty() }
// Rank pages by relevance: full-text content search + keyword/title matching.
val rankedPages = rankPagesByRelevance(queryTerms, bundle.pages, allContent)
Logger.d(tag = TAG) { "Ranked pages: ${rankedPages.take(5).map { "${it.first.id}(${it.second})" }}" }
// Build compact context by extracting only relevant paragraphs.
val contextResult = buildContext(currentPageId, queryTerms, rankedPages, allContent, MAX_CONTEXT_CHARS)
Logger.d(tag = TAG) {
"Context: ${contextResult.parts.size} pages, ${contextResult.totalChars} chars (budget $MAX_CONTEXT_CHARS)"
}
val prompt = buildPrompt(question, contextResult.parts)
Logger.d(tag = TAG) { "Prompt: ${prompt.length} chars, message #$messageCount" }
val chatResult = generateWithChat(prompt)
messageCount++
Logger.d(tag = TAG) { "Response (${chatResult.answer.length} chars): ${chatResult.answer.take(200)}" }
// Merge context pages with any pages mentioned by title in the response (à la Meshtastic-Apple).
val mentionedPages =
bundle.pages.filter { page ->
page.id !in contextResult.usedPageIds && chatResult.answer.contains(page.title, ignoreCase = true)
}
val allSourcePages =
contextResult.usedPageIds.mapNotNull { id -> bundle.pages.find { it.id == id } } + mentionedPages
AIDocAssistantResult.Success(
answer = chatResult.answer,
sourcePages = allSourcePages,
usedOnDeviceModel = chatResult.usedOnDevice,
)
} catch (e: Exception) {
Logger.w(tag = TAG) { "Inference failed: ${e.message}" }
val errorType =
when {
e.message?.contains("BUSY", ignoreCase = true) == true -> DocsAiError.Busy
e.message?.contains("BATTERY", ignoreCase = true) == true -> DocsAiError.Busy
e.message?.contains("BACKGROUND", ignoreCase = true) == true -> DocsAiError.Busy
e.message?.contains("UNAVAILABLE", ignoreCase = true) == true -> DocsAiError.ModelUnavailable
else -> DocsAiError.Unknown
}
val fallbackPages = searchEngine.selectForTokenBudget(question, maxChars = MAX_CONTEXT_CHARS)
AIDocAssistantResult.Error(reason = errorType, suggestedPages = fallbackPages)
}
override fun resetSession() {
chatSession = null
messageCount = 0
Logger.d(tag = TAG) { "Chat session reset" }
}
/** Result from [generateWithChat] indicating which model produced the answer. */
private data class ChatResult(val answer: String, val usedOnDevice: Boolean)
/**
* Uses the Chat API for multi-turn conversation. First message tries on-device for speed; all messages also go
* through the cloud chat session to maintain conversation history for follow-ups.
*/
private suspend fun generateWithChat(prompt: String): ChatResult {
val chat = chatSession ?: groundedModel.startChat().also { chatSession = it }
// First message: try on-device in parallel for speed, use cloud chat as primary.
if (messageCount == 0) {
val onDeviceAnswer =
try {
val response = onDeviceModel.generateContent(prompt)
response.text?.trimEnd()
} catch (e: Exception) {
Logger.d(tag = TAG) { "On-device inference failed: ${e.message}" }
null
}
// If on-device gave a good answer, send it to chat as history context and return it.
if (onDeviceAnswer != null && !looksLikeNoAnswer(onDeviceAnswer)) {
// Still send to cloud chat so it has context for follow-ups (fire and forget).
try {
val groundedPrompt = prompt + MESHTASTIC_URL_HINT
chat.sendMessage(groundedPrompt)
} catch (e: Exception) {
Logger.d(tag = TAG) { "Cloud chat seeding failed (non-fatal): ${e.message}" }
}
return ChatResult(answer = onDeviceAnswer, usedOnDevice = true)
}
}
// Use cloud chat (maintains full conversation history for follow-ups).
val groundedPrompt = prompt + MESHTASTIC_URL_HINT
val response = chat.sendMessage(groundedPrompt)
return ChatResult(
answer = response.text?.trimEnd() ?: "I wasn't able to generate a response.",
usedOnDevice = false,
)
}
/** Heuristic: detect when the model says it can't find the answer in the provided docs. */
private fun looksLikeNoAnswer(answer: String): Boolean {
val lower = answer.lowercase()
return lower.contains("not in the docs") ||
lower.contains("not found in") ||
lower.contains("i don't have information") ||
lower.contains("i couldn't find") ||
lower.contains("not covered in the documentation")
}
private data class ContextResult(val parts: List<String>, val usedPageIds: Set<String>, val totalChars: Int)
/** Builds context parts from ranked pages within the given char budget. */
private fun buildContext(
currentPageId: String?,
queryTerms: List<String>,
rankedPages: List<Pair<DocPage, Int>>,
allContent: Map<DocPage, String>,
budget: Int,
): ContextResult {
val usedPageIds = mutableSetOf<String>()
val contextParts = mutableListOf<String>()
var totalChars = 0
// Current page gets priority.
if (currentPageId != null) {
val content = allContent.entries.find { it.key.id == currentPageId }
if (content != null && content.value.isNotBlank()) {
val pageBudget = MAX_PAGE_CHARS.coerceAtMost(budget)
val extracted = extractRelevantContent(content.key.title, content.value, queryTerms, pageBudget)
if (extracted.isNotBlank()) {
contextParts.add(extracted)
totalChars += extracted.length
usedPageIds.add(currentPageId)
}
}
}
// Add relevant paragraphs from top-ranked pages within budget.
for ((page, _) in rankedPages) {
if (page.id in usedPageIds) continue
if (totalChars >= budget) break
val pageContent = allContent[page] ?: continue
if (pageContent.isBlank()) continue
val snippetBudget = (budget - totalChars).coerceAtMost(MAX_SNIPPET_CHARS)
if (snippetBudget < MIN_USEFUL_SNIPPET) break
val extracted = extractRelevantContent(page.title, pageContent, queryTerms, snippetBudget)
if (extracted.isNotBlank()) {
contextParts.add(extracted)
totalChars += extracted.length
usedPageIds.add(page.id)
}
}
return ContextResult(parts = contextParts, usedPageIds = usedPageIds, totalChars = totalChars)
}
/** Extracts query terms from a question, filtering short/stop words. */
private fun extractQueryTerms(question: String): List<String> = question
.lowercase()
.replace(Regex("[^\\p{L}\\p{N}\\s-]"), " ")
.split(Regex("\\s+"))
.filter { it.length >= 2 && it !in STOP_WORDS }
.distinct()
/**
* Extracts the most relevant content from a page: the paragraphs that contain query terms, with markdown formatting
* stripped for maximum information density.
*/
private fun extractRelevantContent(
title: String,
markdown: String,
queryTerms: List<String>,
maxChars: Int,
): String {
val plainText = stripMarkdown(markdown)
// Split into paragraphs (double newline or section breaks).
val paragraphs = plainText.split(Regex("\n{2,}")).map { it.trim() }.filter { it.length >= MIN_PARAGRAPH_LEN }
// Score each paragraph by how many query terms it contains.
val scored =
paragraphs.map { paragraph ->
val lower = paragraph.lowercase()
val hits = queryTerms.count { term -> lower.contains(term) }
paragraph to hits
}
// Take paragraphs with hits first (sorted by hits desc), then fill with top paragraphs for context.
val withHits = scored.filter { it.second > 0 }.sortedByDescending { it.second }
val withoutHits = scored.filter { it.second == 0 }
val result = StringBuilder("$title: ")
for ((paragraph, _) in withHits + withoutHits) {
if (result.length + paragraph.length + 1 > maxChars) {
// Try to fit a truncated version if we have room.
val remaining = maxChars - result.length - 1
if (remaining > MIN_USEFUL_SNIPPET) {
result.append(paragraph.take(remaining))
}
break
}
result.append(paragraph).append('\n')
}
return result.toString().trim()
}
/** Strips markdown formatting to produce dense plain text. */
private fun stripMarkdown(markdown: String): String = markdown
.replace(Regex("^#{1,6}\\s+", RegexOption.MULTILINE), "") // headers
.replace(Regex("\\[([^]]+)]\\([^)]+\\)"), "$1") // links → text
.replace(Regex("!\\[([^]]*)]\\([^)]+\\)"), "$1") // images → alt
.replace(Regex("[*_]{1,3}([^*_]+)[*_]{1,3}"), "$1") // bold/italic
.replace(Regex("`{1,3}[^`]*`{1,3}"), "") // inline code
.replace(Regex("^[>|\\-*+]\\s?", RegexOption.MULTILINE), "") // block quotes, lists
.replace(Regex("\\|"), " ") // table pipes
.replace(Regex("-{3,}"), "") // horizontal rules
.replace(Regex(" {2,}"), " ") // collapse whitespace
.trim()
/**
* Ranks pages by relevance using full-text content search + keyword/title matching. Returns pages sorted by score
* descending, filtering out zero-score pages.
*/
private fun rankPagesByRelevance(
queryTerms: List<String>,
pages: List<DocPage>,
allContent: Map<DocPage, String>,
): List<Pair<DocPage, Int>> = pages
.map { page ->
var score = 0
val content = allContent[page]?.lowercase().orEmpty()
for (term in queryTerms) {
if (content.contains(term)) score += CONTENT_MATCH_SCORE
if (page.title.lowercase().contains(term)) score += TITLE_MATCH_SCORE
if (page.keywords.any { it.lowercase().contains(term) }) score += KEYWORD_MATCH_SCORE
if (page.aliases.any { it.lowercase().contains(term) }) score += ALIAS_MATCH_SCORE
}
page to score
}
.filter { it.second > 0 }
.sortedByDescending { it.second }
private fun buildPrompt(question: String, contextParts: List<String>): String {
val context =
if (contextParts.isNotEmpty()) {
contextParts.joinToString("\n\n")
} else {
FALLBACK_CONTEXT
}
return """
|Bundled app documentation:
|$context
|
|User question: $question
"""
.trimMargin()
}
companion object {
private const val TAG = "ChirpyAI"
/** Gemini 3.1 Flash-Lite — latest stable model (2026-05-07), free tier, supports grounding. */
private const val MODEL_NAME = "gemini-3.1-flash-lite"
private const val SYSTEM_INSTRUCTION =
"""You are Chirpy, the friendly AI assistant built into the Meshtastic Android app. You help users understand mesh networking, configure their Meshtastic nodes, troubleshoot connectivity issues, and get the most out of the Meshtastic ecosystem.
Personality: Helpful, concise, enthusiastic about mesh networking. Use short paragraphs. Include relevant emoji sparingly (📡 🔋 📍).
Knowledge sources (in priority order):
1. Bundled app documentation provided as context below
2. Official Meshtastic documentation at meshtastic.org/docs
3. Official Meshtastic GitHub repositories (github.com/meshtastic)
4. General LoRa/mesh networking knowledge
Guidelines:
- Answer the user's question directly and helpfully
- When the bundled docs cover the topic, cite them
- When the bundled docs don't cover it, use your knowledge of official Meshtastic sources — don't refuse to help
- Only reference official Meshtastic sources (meshtastic.org, github.com/meshtastic) — never cite random forums, blogs, or third-party sites
- For firmware-specific or hardware-specific questions beyond app scope, point users to meshtastic.org/docs
- Keep answers concise (2-4 short paragraphs max) unless the user asks for detail
- If you're truly unsure about something Meshtastic-specific, say so honestly rather than guessing"""
/** Total context char budget — sized for on-device Nano (~4K tokens ≈ 10K chars for context + prompt). */
private const val MAX_CONTEXT_CHARS = 8_000
/** Max chars for the current page (gets priority). */
private const val MAX_PAGE_CHARS = 4_000
/** Max chars per additional page snippet. */
private const val MAX_SNIPPET_CHARS = 2_000
/** Minimum useful snippet size — don't bother with tiny fragments. */
private const val MIN_USEFUL_SNIPPET = 100
/** Minimum paragraph length to consider. */
private const val MIN_PARAGRAPH_LEN = 20
// Scoring weights for page ranking
private const val CONTENT_MATCH_SCORE = 3
private const val TITLE_MATCH_SCORE = 10
private const val KEYWORD_MATCH_SCORE = 7
private const val ALIAS_MATCH_SCORE = 5
private val STOP_WORDS =
setOf(
"the",
"is",
"at",
"in",
"on",
"to",
"of",
"an",
"it",
"do",
"me",
"my",
"or",
"if",
"be",
"as",
"by",
"so",
"we",
"he",
"up",
"no",
"am",
"us",
)
private const val FALLBACK_CONTEXT =
"Meshtastic is an open-source mesh networking platform for LoRa radios. " +
"The app connects to Meshtastic devices via Bluetooth or WiFi to send messages, " +
"share location, and manage mesh network settings like channels, nodes, and modules."
/** URLs appended to prompts for the cloud model to leverage URL context tool. Only official sources. */
private const val MESHTASTIC_URL_HINT =
"\n\nFor additional context, you may reference these official sources:" +
"\n- https://meshtastic.org/docs/" +
"\n- https://github.com/meshtastic/Meshtastic-Android" +
"\n- https://github.com/meshtastic/firmware" +
"\n- https://github.com/meshtastic/protobufs"
}
}

View File

@@ -19,5 +19,5 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.meshtastic.app.map.prefs.di.GoogleMapsKoinModule
@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class])
@Module(includes = [GoogleNetworkModule::class, GoogleMapsKoinModule::class, GoogleAiModule::class])
class FlavorModule

View File

@@ -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 android.content.Context
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.app.ai.GeminiNanoDocAssistant
import org.meshtastic.app.translation.MlKitDocTranslator
import org.meshtastic.feature.docs.ai.AIDocAssistant
import org.meshtastic.feature.docs.data.DocBundleLoader
import org.meshtastic.feature.docs.data.KeywordSearchEngine
import org.meshtastic.feature.docs.translation.DocTranslationCache
import org.meshtastic.feature.docs.translation.DocTranslationService
/** Provides the on-device Gemini Nano AI assistant for the Google flavor. */
@Module
class GoogleAiModule {
@Single
fun aiDocAssistant(searchEngine: KeywordSearchEngine, bundleLoader: DocBundleLoader): AIDocAssistant =
GeminiNanoDocAssistant(searchEngine, bundleLoader)
@Single
fun docTranslationCache(context: Context): DocTranslationCache =
DocTranslationCache(cacheDir = context.cacheDir.toOkioPath(), fileSystem = FileSystem.SYSTEM)
@Single fun docTranslationService(cache: DocTranslationCache): DocTranslationService = MlKitDocTranslator(cache)
}

View File

@@ -14,16 +14,20 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.app.map.component
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -52,55 +56,56 @@ import kotlin.math.roundToInt
internal fun MapFilterDropdown(expanded: Boolean, onDismissRequest: () -> Unit, mapViewModel: MapViewModel) {
val mapFilterState by mapViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = stringResource(Res.string.only_favorites),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_precision_circle)) },
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
},
)
HorizontalDivider()
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.only_favorites)) },
onClick = { mapViewModel.toggleOnlyFavorites() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Favorite,
contentDescription = stringResource(Res.string.only_favorites),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.onlyFavorites,
onCheckedChange = { mapViewModel.toggleOnlyFavorites() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_waypoints)) },
onClick = { mapViewModel.toggleShowWaypointsOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.PinDrop,
contentDescription = stringResource(Res.string.show_waypoints),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showWaypoints,
onCheckedChange = { mapViewModel.toggleShowWaypointsOnMap() },
)
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.show_precision_circle)) },
onClick = { mapViewModel.toggleShowPrecisionCircleOnMap() },
leadingIcon = {
Icon(
imageVector = MeshtasticIcons.Lens,
contentDescription = stringResource(Res.string.show_precision_circle),
)
},
trailingIcon = {
Checkbox(
checked = mapFilterState.showPrecisionCircle,
onCheckedChange = { mapViewModel.toggleShowPrecisionCircleOnMap() },
)
},
)
}
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
val filterOptions = LastHeardFilter.entries
val selectedIndex = filterOptions.indexOf(mapFilterState.lastHeardFilter)

View File

@@ -14,12 +14,16 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package org.meshtastic.app.map.component
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuGroup
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -58,38 +62,16 @@ internal fun MapTypeDropdown(
)
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
googleMapTypes.forEach { (name, type) ->
DropdownMenuItem(
text = { Text(name) },
onClick = {
mapViewModel.setSelectedGoogleMapType(type)
onDismissRequest() // Close menu
},
trailingIcon =
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
if (customTileProviders.isNotEmpty()) {
HorizontalDivider()
customTileProviders.forEach { config ->
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
googleMapTypes.forEach { (name, type) ->
DropdownMenuItem(
text = { Text(config.name) },
text = { Text(name) },
onClick = {
mapViewModel.selectCustomTileProvider(config)
onDismissRequest() // Close menu
mapViewModel.setSelectedGoogleMapType(type)
onDismissRequest()
},
trailingIcon =
if (selectedCustomUrl == config.urlTemplate) {
if (selectedCustomUrl == null && selectedGoogleMapType == type) {
{
Icon(
MeshtasticIcons.Check,
@@ -102,7 +84,31 @@ internal fun MapTypeDropdown(
)
}
}
HorizontalDivider()
if (customTileProviders.isNotEmpty()) {
DropdownMenuGroup(shapes = MenuDefaults.groupShapes()) {
customTileProviders.forEach { config ->
DropdownMenuItem(
text = { Text(config.name) },
onClick = {
mapViewModel.selectCustomTileProvider(config)
onDismissRequest()
},
trailingIcon =
if (selectedCustomUrl == config.urlTemplate) {
{
Icon(
MeshtasticIcons.Check,
contentDescription = stringResource(Res.string.selected_map_type),
)
}
} else {
null
},
)
}
}
}
DropdownMenuItem(
text = { Text(stringResource(Res.string.manage_custom_tile_sources)) },
onClick = {

Some files were not shown because too many files have changed in this diff Show More