feat(docs): In-app documentation browser with Jekyll site and Docusaurus sync (#5445)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-18 23:00:22 -05:00
committed by GitHub
parent f5128798a8
commit fc0df1a79a
201 changed files with 12471 additions and 136 deletions

View File

@@ -3,6 +3,21 @@
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
# Format: ## YYYY-MM-DD — <summary>
## 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.

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

@@ -0,0 +1,63 @@
name: Docs Deploy (Beta)
on:
push:
branches: [main]
paths:
- 'docs/**'
- 'feature/docs/**'
- 'build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt'
- '.github/workflows/docs-deploy.yml'
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: docs-deploy
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: true
fetch-depth: 0
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: false
- name: Generate Docs Bundle
run: ./gradlew generateDocsBundle -Pdocs.channel=beta -Pci=true
- name: Validate Docs Bundle
run: ./gradlew validateDocsBundle -Pdocs.channel=beta -Pci=true
- name: Generate Site Artifact
run: ./gradlew publishDocsSite -Pdocs.channel=beta -Pci=true
- name: Upload Pages Artifact
uses: actions/upload-pages-artifact@v3
with:
path: build/_site/
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

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@v4
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/(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@v7
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/user/` or `docs/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/` automatically flows to all three.',
'',
'**Changed source files:**',
'```',
viewsChanged,
'```',
'',
'**What to check:**',
'| Changed area | Likely doc page |',
'|---|---|',
'| `feature/messaging/` | `docs/user/messages-and-channels.md` |',
'| `feature/node/` | `docs/user/nodes.md` or `docs/user/node-metrics.md` |',
'| `feature/map/` | `docs/user/map-and-waypoints.md` |',
'| `feature/connections/` | `docs/user/connections.md` |',
'| `feature/settings/` | `docs/user/settings-radio-user.md` or `docs/user/settings-module-admin.md` |',
'| `feature/firmware/` | `docs/user/firmware.md` |',
'| `feature/intro/` | `docs/user/onboarding.md` |',
'| `feature/discovery/` | `docs/user/discovery.md` |',
'| `feature/docs/` | Internal docs infrastructure |',
'| `core/ui/` | `docs/developer/codebase.md` or component-specific user pages |',
'',
'**New page checklist** (if adding a new doc page):',
'1. Create the `.md` file in `docs/user/` or `docs/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@v7
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/` 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/ 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@v4
with:
fetch-depth: 1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Validate internal links
run: node scripts/validate-doc-links.js docs
- 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/user/*.md docs/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@v4
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@v7
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@v7
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@v7
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.`,
});
}
}

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

@@ -0,0 +1,82 @@
name: Docs Release
on:
push:
tags:
- 'v*.*.*'
permissions:
contents: write
pages: write
id-token: write
concurrency:
group: docs-release
cancel-in-progress: false
jobs:
release-docs:
runs-on: ubuntu-24.04
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
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: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
with:
cache-read-only: true
- name: Generate Docs Bundle
run: ./gradlew generateDocsBundle -Pdocs.channel=release -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
- name: Validate Docs Bundle
run: ./gradlew validateDocsBundle -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
- name: Generate Site Artifact
run: ./gradlew publishDocsSite -Pdocs.channel=release -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
- name: Update Versions Manifest
run: |
VERSION=${{ steps.version.outputs.version }}
# Prepend new version entry to versions.yml
cat <<EOF > /tmp/new_version.yml
- name: "$VERSION"
url: /v$VERSION/
prerelease: false
current: true
EOF
# Mark existing entries as not current
sed -i 's/current: true/current: false/' docs/_data/versions.yml
# Prepend new entry
cat /tmp/new_version.yml docs/_data/versions.yml > /tmp/versions_merged.yml
mv /tmp/versions_merged.yml docs/_data/versions.yml
- name: Upload Pages Artifact
uses: actions/upload-pages-artifact@v3
with:
path: build/_site/
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -177,6 +177,7 @@ jobs:
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@v4
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@v4
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@v7
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.

15
.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,13 @@ firebase-debug.log
/coil/
/kable/
.opencode/
# Synced docs in composeResources (generated from docs/ source by syncDocsToComposeResources task)
feature/docs/src/commonMain/composeResources/files/docs/user/
feature/docs/src/commonMain/composeResources/files/docs/developer/
feature/docs/src/commonMain/composeResources/files/docs/assets/
/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

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

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

@@ -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

@@ -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

@@ -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,43 @@
/*
* 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.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())
@Single fun docTranslationService(cache: DocTranslationCache): DocTranslationService = MlKitDocTranslator(cache)
}

View File

@@ -0,0 +1,136 @@
/*
* 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.translation
import co.touchlab.kermit.Logger
import com.google.mlkit.common.model.DownloadConditions
import com.google.mlkit.common.model.RemoteModelManager
import com.google.mlkit.nl.translate.TranslateLanguage
import com.google.mlkit.nl.translate.TranslateRemoteModel
import com.google.mlkit.nl.translate.Translation
import com.google.mlkit.nl.translate.TranslatorOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import org.meshtastic.feature.docs.translation.DocTranslationCache
import org.meshtastic.feature.docs.translation.DocTranslationService
import org.meshtastic.feature.docs.translation.DownloadResult
import org.meshtastic.feature.docs.translation.MarkdownTranslationSegmenter
import org.meshtastic.feature.docs.translation.TranslationResult
import org.meshtastic.feature.docs.translation.md5Hash
import kotlin.coroutines.resume
/**
* ML Kit-powered document translation service for the Google flavor.
*
* Downloads language models on-demand (~30MB each) and translates markdown content while preserving structure via
* [MarkdownTranslationSegmenter].
*/
class MlKitDocTranslator(private val cache: DocTranslationCache) : DocTranslationService {
private val modelManager = RemoteModelManager.getInstance()
override suspend fun translatePage(pageId: String, markdown: String, targetLocale: String): TranslationResult {
val sourceHash = md5Hash(markdown)
// Check cache first
cache.get(pageId, targetLocale, sourceHash)?.let { cached ->
return TranslationResult.Success(cached)
}
// Check if language is supported by ML Kit
val targetLang = TranslateLanguage.fromLanguageTag(targetLocale) ?: return TranslationResult.Unavailable
// Auto-download model if not present
if (!isModelDownloaded(targetLang)) {
Logger.i(tag = "MlKitDocTranslator") {
"Downloading model for $targetLocale (~${ESTIMATED_MODEL_SIZE_MB}MB)"
}
val downloadResult = downloadLanguageModel(targetLocale)
if (downloadResult is DownloadResult.Failed) {
Logger.w(tag = "MlKitDocTranslator") { "Model download failed: ${downloadResult.reason}" }
return TranslationResult.ModelDownloadRequired(targetLocale, ESTIMATED_MODEL_SIZE_MB)
}
}
// Perform translation
return try {
val options =
TranslatorOptions.Builder()
.setSourceLanguage(TranslateLanguage.ENGLISH)
.setTargetLanguage(targetLang)
.build()
val translator = Translation.getClient(options)
try {
val translated =
MarkdownTranslationSegmenter.translateMarkdown(markdown) { text ->
suspendCancellableCoroutine { cont ->
translator
.translate(text)
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { e ->
Logger.w(tag = "MlKitDocTranslator") {
"Segment translation failed, using source: ${e.message}"
}
cont.resume(text)
}
}
}
// Cache the result
cache.put(pageId, targetLocale, sourceHash, translated)
TranslationResult.Success(translated)
} finally {
translator.close()
}
} catch (e: Exception) {
Logger.w(tag = "MlKitDocTranslator") { "Translation failed for $pageId to $targetLocale: ${e.message}" }
TranslationResult.Unavailable
}
}
override suspend fun isLanguageAvailable(locale: String): Boolean {
val lang = TranslateLanguage.fromLanguageTag(locale) ?: return false
return isModelDownloaded(lang)
}
override suspend fun downloadLanguageModel(locale: String): DownloadResult {
val lang =
TranslateLanguage.fromLanguageTag(locale) ?: return DownloadResult.Failed("Unsupported language: $locale")
val model = TranslateRemoteModel.Builder(lang).build()
val conditions = DownloadConditions.Builder().build()
return suspendCancellableCoroutine { cont ->
modelManager
.download(model, conditions)
.addOnSuccessListener { cont.resume(DownloadResult.Success) }
.addOnFailureListener { e -> cont.resume(DownloadResult.Failed(e.message ?: "Download failed")) }
}
}
private suspend fun isModelDownloaded(lang: String): Boolean = suspendCancellableCoroutine { cont ->
val model = TranslateRemoteModel.Builder(lang).build()
modelManager
.isModelDownloaded(model)
.addOnSuccessListener { cont.resume(it) }
.addOnFailureListener { cont.resume(false) }
}
companion object {
private const val ESTIMATED_MODEL_SIZE_MB = 30
}
}

View File

@@ -47,6 +47,7 @@ import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.docs.di.FeatureDocsModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@@ -85,6 +86,7 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
FeatureDocsModule::class,
FeatureFirmwareModule::class,
FeatureIntroModule::class,
FeatureWidgetModule::class,

View File

@@ -43,6 +43,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.docs.navigation.docsEntries
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -88,6 +89,7 @@ fun MainScreen() {
channelsGraph(backStack)
connectionsGraph(backStack)
settingsGraph(backStack)
docsEntries(backStack)
firmwareGraph(backStack)
wifiProvisionGraph(backStack)
}

View File

@@ -60,6 +60,8 @@ class KoinVerificationTest {
// declared as known types even though they're never resolved from the graph.
BleLogLevel::class,
BleLogFormat::class,
okio.Path::class,
okio.FileSystem::class,
),
injections =
injectedParameters(

View File

@@ -194,6 +194,11 @@ gradlePlugin {
implementationClass = "RootConventionPlugin"
}
register("docs") {
id = "meshtastic.docs"
implementationClass = "org.meshtastic.buildlogic.DocsTasks"
}
register("publishing") {
id = "meshtastic.publishing"
implementationClass = "PublishingConventionPlugin"

View File

@@ -67,9 +67,16 @@ private fun Project.registerKmpSmokeCompileTask() {
dependsOn("$path:compileKotlinJvm")
dependsOn("$path:compileKotlinIosSimulatorArm64")
}
// Compile androidDeviceTest sources so instrumented test breakages are caught early.
// These tests require a device/emulator to *run*, but compilation alone is cheap.
DEVICE_TEST_MODULES.forEach { path -> dependsOn("$path:compileAndroidDeviceTest") }
}
}
/** KMP modules that declare `withDeviceTest {}` and therefore have `compileAndroidDeviceTest` tasks. */
private val DEVICE_TEST_MODULES = listOf(":core:database", ":core:model")
/** All modules included in `settings.gradle.kts`. Update this list when adding or removing modules. */
private val ALL_MODULES_FULL =
listOf(

View File

@@ -20,12 +20,17 @@ import com.android.build.api.variant.LibraryAndroidComponentsExtension
import org.gradle.api.Project
/**
* Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder. Otherwise,
* these projects would be compiled, packaged, installed and ran only to end-up with the following message:
* Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` or `androidDeviceTest`
* folder. Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following
* message:
* > Starting 0 tests on AVD
*
* Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors.
*/
internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests(project: Project) = beforeVariants {
it.androidTest.enable = it.androidTest.enable && project.projectDir.resolve("src/androidTest").exists()
it.androidTest.enable = it.androidTest.enable &&
(
project.projectDir.resolve("src/androidTest").exists() ||
project.projectDir.resolve("src/androidDeviceTest").exists()
)
}

View File

@@ -0,0 +1,404 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.buildlogic
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.register
import java.io.File
/**
* Registers docs generation, validation, and publishing tasks.
*
* Tasks:
* - generateDocsBundle: Converts markdown to HTML + index.json
* - validateDocsBundle: Schema, size, and asset validation
* - publishDocsSite: Generates _site/ artifact for Pages
*/
class DocsTasks : Plugin<Project> {
override fun apply(project: Project) {
val docsDir = project.rootProject.layout.projectDirectory.dir("docs")
val outputDir = project.layout.buildDirectory.dir("generated/docs")
project.tasks.register<GenerateDocsBundleTask>("generateDocsBundle") {
group = "documentation"
description = "Generate packaged docs artifacts and keyword index from markdown source."
sourceDir.set(docsDir)
generatedOutputDir.set(outputDir.map { it.dir("common") })
channel.set(project.providers.gradleProperty("docs.channel").orElse("beta"))
version.set(project.providers.gradleProperty("docs.version").orElse("beta"))
}
project.tasks.register<ValidateDocsBundleTask>("validateDocsBundle") {
group = "documentation"
description = "Validate keyword index schema, bundle size, and asset references."
dependsOn("generateDocsBundle")
bundleDir.set(outputDir.map { it.dir("common") })
schemaFile.set(
project.rootProject.layout.projectDirectory
.file("specs/003-app-docs-markdown/contracts/keyword-index-schema.json")
)
}
project.tasks.register<PublishDocsSiteTask>("publishDocsSite") {
group = "documentation"
description = "Assemble the final Pages artifact from generated docs."
dependsOn("generateDocsBundle")
sourceDir.set(docsDir)
bundleDir.set(outputDir.map { it.dir("common") })
siteOutputDir.set(project.layout.buildDirectory.dir("_site"))
channel.set(project.providers.gradleProperty("docs.channel").orElse("beta"))
version.set(project.providers.gradleProperty("docs.version").orElse("beta"))
}
}
}
abstract class GenerateDocsBundleTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDir: DirectoryProperty
@get:OutputDirectory
abstract val generatedOutputDir: DirectoryProperty
@get:Input
abstract val channel: Property<String>
@get:Input
abstract val version: Property<String>
@TaskAction
fun generate() {
val src = sourceDir.get().asFile
val out = generatedOutputDir.get().asFile
out.mkdirs()
val indexEntries = mutableListOf<String>()
var pageCount = 0
// Process English user and developer directories
listOf("user", "developer").forEach { section ->
val sectionDir = File(src, section)
if (!sectionDir.exists()) return@forEach
sectionDir.listFiles { f -> f.extension == "md" }?.sortedBy { it.name }?.forEach { mdFile ->
val frontmatter = parseFrontmatter(mdFile)
val id = mdFile.nameWithoutExtension
val title = frontmatter["title"] ?: id.replace("-", " ").replaceFirstChar { it.uppercase() }
val navOrder = frontmatter["nav_order"]?.toIntOrNull() ?: 999
val aliases = parseListField(frontmatter["aliases_raw"] ?: "")
val keywords = extractKeywords(mdFile, title)
val charCount = mdFile.readText().length
// Generate simple HTML wrapper
val htmlDir = File(out, "docs/$section")
htmlDir.mkdirs()
val htmlFile = File(htmlDir, "$id.html")
htmlFile.writeText(generateHtml(mdFile, title, "en"))
// Build index entry
val keywordsJson = keywords.joinToString(", ") { "\"$it\"" }
val aliasesJson = aliases.joinToString(", ") { "\"$it\"" }
indexEntries.add("""
| {
| "id": "$id",
| "title": "$title",
| "section": "$section",
| "locale": "en",
| "resourcePath": "docs/$section/$id.html",
| "navOrder": $navOrder,
| "keywords": [$keywordsJson],
| "aliases": [$aliasesJson],
| "charCount": $charCount
| }
""".trimMargin())
pageCount++
}
}
// Process Crowdin locale directories: docs/{qualifier}/user/*.md
// Crowdin %android_code% produces: fr, pt-rBR, zh-rCN, zh-rTW
val localePattern = Regex("^[a-z]{2,3}(-r[A-Z]{2})?$")
src.listFiles { f -> f.isDirectory && localePattern.matches(f.name) }
?.sortedBy { it.name }
?.forEach { localeDir ->
val locale = localeDir.name
listOf("user").forEach { section ->
val localeSectionDir = File(localeDir, section)
if (!localeSectionDir.exists()) return@forEach
localeSectionDir.listFiles { f -> f.extension == "md" }?.sortedBy { it.name }?.forEach { mdFile ->
val frontmatter = parseFrontmatter(mdFile)
val id = mdFile.nameWithoutExtension
val title = frontmatter["title"]
?: id.replace("-", " ").replaceFirstChar { it.uppercase() }
val navOrder = frontmatter["nav_order"]?.toIntOrNull() ?: 999
val keywords = extractKeywords(mdFile, title)
val charCount = mdFile.readText().length
// Generate locale-qualified HTML
val htmlDir = File(out, "docs/$locale/$section")
htmlDir.mkdirs()
val htmlFile = File(htmlDir, "$id.html")
htmlFile.writeText(generateHtml(mdFile, title, locale))
// Build locale index entry
val keywordsJson = keywords.joinToString(", ") { "\"$it\"" }
indexEntries.add("""
| {
| "id": "$id",
| "title": "$title",
| "section": "$section",
| "locale": "$locale",
| "resourcePath": "docs/$locale/$section/$id.html",
| "navOrder": $navOrder,
| "keywords": [$keywordsJson],
| "aliases": [],
| "charCount": $charCount
| }
""".trimMargin())
pageCount++
}
}
}
// Write index.json
val indexFile = File(out, "index.json")
indexFile.writeText("[\n${indexEntries.joinToString(",\n")}\n]")
// Write shared CSS
val cssDir = File(out, "docs/styles")
cssDir.mkdirs()
File(cssDir, "docs.css").writeText(generateCss())
// Write locales manifest (for consumers that need to know available translations)
val localesManifest = src.listFiles { f -> f.isDirectory && localePattern.matches(f.name) }
?.map { it.name }?.sorted() ?: emptyList()
val manifestFile = File(out, "locales.json")
manifestFile.writeText(localesManifest.joinToString(", ", "[", "]") { "\"$it\"" })
logger.lifecycle("Generated docs bundle: $pageCount pages (${localesManifest.size} locales), channel=${channel.get()}, version=${version.get()}")
}
private fun parseFrontmatter(file: File): Map<String, String> {
val lines = file.readLines()
if (lines.firstOrNull()?.trim() != "---") return emptyMap()
val endIndex = lines.drop(1).indexOfFirst { it.trim() == "---" }
if (endIndex < 0) return emptyMap()
val fmLines = lines.subList(1, endIndex + 1)
val result = mutableMapOf<String, String>()
var inAliases = false
val aliasesBuilder = StringBuilder()
for (line in fmLines) {
if (line.startsWith("aliases:")) {
inAliases = true
continue
}
if (inAliases) {
if (line.startsWith(" - ")) {
aliasesBuilder.append(line.removePrefix(" - ").trim()).append(",")
} else {
inAliases = false
result["aliases_raw"] = aliasesBuilder.toString().trimEnd(',')
}
}
if (!inAliases && line.contains(":")) {
val (key, value) = line.split(":", limit = 2)
result[key.trim()] = value.trim()
}
}
if (inAliases) result["aliases_raw"] = aliasesBuilder.toString().trimEnd(',')
return result
}
private fun parseListField(raw: String): List<String> =
raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
private fun extractKeywords(file: File, title: String): List<String> {
val text = file.readText().lowercase()
val keywords = mutableSetOf<String>()
// Add title words
title.lowercase().split(Regex("[^a-z0-9]+")).filter { it.length >= 3 }.forEach { keywords.add(it) }
// Extract headings
Regex("^#{1,3}\\s+(.+)$", RegexOption.MULTILINE).findAll(text).forEach { match ->
match.groupValues[1].split(Regex("[^a-z0-9]+")).filter { it.length >= 3 }.forEach { keywords.add(it) }
}
return keywords.toList().take(30)
}
private fun generateHtml(mdFile: File, title: String, locale: String = "en"): String {
val content = mdFile.readText()
// Strip frontmatter
.replace(Regex("^---[\\s\\S]*?---\\s*", RegexOption.MULTILINE), "")
.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
val dir = if (locale == "ar") "rtl" else "ltr"
// Locale pages are one level deeper: docs/{locale}/user/foo.html vs docs/user/foo.html
val cssPath = if (locale != "en") "../../styles/docs.css" else "../styles/docs.css"
return """
|<!DOCTYPE html>
|<html lang="$locale" dir="$dir">
|<head>
| <meta charset="UTF-8">
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
| <title>$title</title>
| <link rel="stylesheet" href="$cssPath">
|</head>
|<body data-page="${mdFile.nameWithoutExtension}" data-locale="$locale">
|<pre class="markdown-content">$content</pre>
|</body>
|</html>
""".trimMargin()
}
private fun generateCss(): String = """
|:root {
| --primary: #2C2D3C;
| --accent: #67EA94;
| --accent-text: #3FB86D;
| --info: #5C6BC0;
| --warning: #E8A33E;
| --error: #E05252;
|}
|body {
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
| line-height: 1.6;
| padding: 16px;
| color: var(--primary);
| max-width: 800px;
| margin: 0 auto;
|}
|@media (prefers-color-scheme: dark) {
| body { background: #1A1B26; color: #ECEDF3; }
| pre { background: #2C2D3C; }
|}
|pre.markdown-content {
| white-space: pre-wrap;
| word-wrap: break-word;
| font-family: inherit;
| background: transparent;
| padding: 0;
| margin: 0;
|}
|.callout-info { border-left: 4px solid var(--info); padding: 12px; background: #E8EAF6; margin: 12px 0; }
|.callout-warning { border-left: 4px solid var(--warning); padding: 12px; background: #FFF3E0; margin: 12px 0; }
|.callout-error { border-left: 4px solid var(--error); padding: 12px; background: #FDEAEA; margin: 12px 0; }
""".trimMargin()
}
abstract class ValidateDocsBundleTask : DefaultTask() {
@get:InputDirectory
@get:Optional
abstract val bundleDir: DirectoryProperty
@get:InputFile
@get:Optional
abstract val schemaFile: RegularFileProperty
@TaskAction
fun validate() {
val dir = bundleDir.get().asFile
val indexFile = File(dir, "index.json")
// Check index exists
if (!indexFile.exists()) {
throw org.gradle.api.GradleException("index.json not found in ${dir.absolutePath}")
}
// Check index is valid JSON array
val indexContent = indexFile.readText()
if (!indexContent.trimStart().startsWith("[")) {
throw org.gradle.api.GradleException("index.json must be a JSON array")
}
// Check bundle size
val totalSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
val sizeMb = totalSize / (1024.0 * 1024.0)
if (sizeMb > 10.0) {
throw org.gradle.api.GradleException("Bundle size ${String.format("%.2f", sizeMb)} MB exceeds 10 MB hard limit")
}
if (sizeMb > 8.0) {
logger.warn("WARNING: Bundle size ${String.format("%.2f", sizeMb)} MB exceeds 8 MB warning threshold")
}
// Check all referenced pages exist
val pagePattern = Regex("\"resourcePath\"\\s*:\\s*\"([^\"]+)\"")
val referencedPaths = pagePattern.findAll(indexContent).map { it.groupValues[1] }.toList()
val missingPages = referencedPaths.filter { !File(dir, it).exists() }
if (missingPages.isNotEmpty()) {
throw org.gradle.api.GradleException("Missing page files: ${missingPages.joinToString()}")
}
logger.lifecycle("Docs bundle validation PASSED: ${referencedPaths.size} pages, ${String.format("%.2f", sizeMb)} MB")
}
}
abstract class PublishDocsSiteTask : DefaultTask() {
@get:InputDirectory
abstract val sourceDir: DirectoryProperty
@get:InputDirectory
abstract val bundleDir: DirectoryProperty
@get:OutputDirectory
abstract val siteOutputDir: DirectoryProperty
@get:Input
abstract val channel: Property<String>
@get:Input
abstract val version: Property<String>
@TaskAction
fun publish() {
val siteDir = siteOutputDir.get().asFile
val channelPath = if (channel.get() == "release") "v${version.get()}" else channel.get()
val outDir = File(siteDir, channelPath)
outDir.mkdirs()
// Copy generated bundle to site output
val bundle = bundleDir.get().asFile
bundle.copyRecursively(outDir, overwrite = true)
// Copy source markdown for Jekyll processing
val src = sourceDir.get().asFile
src.listFiles()?.filter { it.name != "_site" && it.name != ".jekyll-cache" }?.forEach { f ->
if (f.isDirectory) f.copyRecursively(File(outDir, f.name), overwrite = true)
else f.copyTo(File(outDir, f.name), overwrite = true)
}
logger.lifecycle("Published docs site to: ${outDir.absolutePath} (channel=$channelPath)")
}
}

View File

@@ -40,6 +40,7 @@ plugins {
alias(libs.plugins.test.retry) apply false
alias(libs.plugins.flatpak.gradle.generator)
alias(libs.plugins.meshtastic.root)
id("meshtastic.docs")
}
dependencies {

View File

@@ -21,6 +21,14 @@ import android.icu.util.ULocale
import android.os.Build
import java.util.Locale
actual fun currentLocaleCode(): String = Locale.getDefault().language
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country
return if (country.isNotEmpty()) "${locale.language}-r$country" else locale.language
}
@Suppress("MagicNumber")
actual fun getSystemMeasurementSystem(): MeasurementSystem {
val locale = Locale.getDefault()

View File

@@ -24,3 +24,12 @@ enum class MeasurementSystem {
/** returns the system's preferred measurement system. */
expect fun getSystemMeasurementSystem(): MeasurementSystem
/** Returns the device's current locale as a 2-letter ISO 639-1 language code (e.g. "en", "es", "fr"). */
expect fun currentLocaleCode(): String
/**
* Returns the device locale as a CMP resource qualifier string. Examples: "pt-rBR", "zh-rCN", "fr" (no region when not
* specified). Use this to construct locale-qualified file resource paths like "files-$qualifier/docs/...".
*/
expect fun currentLocaleQualifier(): String

View File

@@ -40,6 +40,10 @@ actual object DateFormatter {
actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.METRIC
actual fun currentLocaleCode(): String = "en"
actual fun currentLocaleQualifier(): String = "en"
actual fun String?.isValidAddress(): Boolean = false
actual interface CommonParcelable

View File

@@ -88,6 +88,14 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem =
else -> MeasurementSystem.METRIC
}
actual fun currentLocaleCode(): String = Locale.getDefault().language
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country
return if (country.isNotEmpty()) "${locale.language}-r$country" else locale.language
}
actual fun String?.isValidAddress(): Boolean {
val value = this?.trim()
return when {

View File

@@ -18,6 +18,9 @@ package org.meshtastic.core.database
import android.app.Application
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.Dispatchers
@@ -27,6 +30,7 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.di.CoroutineDispatchers
@RunWith(AndroidJUnit4::class)
@@ -34,10 +38,13 @@ class DatabaseManagerLegacyCleanupTest {
@Test
fun deletes_legacy_db_on_switch_when_flag_not_set() = runBlocking {
val app = ApplicationProvider.getApplicationContext<Application>()
val prefs = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
ContextServices.app = app
val datastoreName = "db-manager-prefs-test-${System.nanoTime()}"
val datastore = createDatabaseDataStore(datastoreName)
// Reset the one-time flag
prefs.edit().remove(DatabaseConstants.LEGACY_DB_CLEANED_KEY).apply()
val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
datastore.edit { it.remove(legacyCleanedKey) }
// Ensure legacy DB file exists
val legacyName = DatabaseConstants.LEGACY_DB_NAME
@@ -48,7 +55,7 @@ class DatabaseManagerLegacyCleanupTest {
val testDispatchers =
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
val manager = DatabaseManager(app, testDispatchers)
val manager = DatabaseManager(datastore, testDispatchers)
// Switch to a non-null address so active DB != legacy
manager.switchActiveDatabase("01:23:45:67:89:AB")
@@ -61,5 +68,8 @@ class DatabaseManagerLegacyCleanupTest {
}
assertFalse("Legacy DB should be deleted after switch", legacyFile.exists())
// Clean up
app.preferencesDataStoreFile(datastoreName).delete()
}
}

View File

@@ -18,12 +18,15 @@ package org.meshtastic.core.database
import androidx.room3.Room
import androidx.room3.testing.MigrationTestHelper
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase.Companion.configureCommon
import java.io.File
import java.io.IOException
@RunWith(AndroidJUnit4::class)
@@ -35,16 +38,21 @@ class MeshtasticDatabaseTest {
@get:Rule
val helper: MigrationTestHelper =
MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), MeshtasticDatabase::class.java)
MigrationTestHelper(
instrumentation = InstrumentationRegistry.getInstrumentation(),
file = File("schemas"),
driver = BundledSQLiteDriver(),
databaseClass = MeshtasticDatabase::class,
)
@org.junit.Ignore("KMP Android Library does not package Room schemas into test assets currently")
@Test
@Throws(IOException::class)
fun migrateAll() {
fun migrateAll(): Unit = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
// Create earliest version of the database.
helper.createDatabase(TEST_DB, 3).apply { close() }
helper.createDatabase(version = 3).close()
// Open latest version of the database. Room validates the schema
// once all migrations execute.
@@ -55,9 +63,6 @@ class MeshtasticDatabaseTest {
)
.configureCommon()
.build()
.apply {
openHelper.writableDatabase
close()
}
.close()
}
}

View File

@@ -132,6 +132,7 @@ object DeepLinkRouter {
}
}
@Suppress("ReturnCount", "MagicNumber")
private fun routeSettings(segments: List<String>): List<NavKey> {
var destNum: Int? = null
var subRouteStr: String? = null
@@ -153,6 +154,17 @@ object DeepLinkRouter {
return listOf(SettingsRoute.Settings(destNum))
}
// Handle helpDocs/{pageId} pattern
if (subRouteStr == "helpdocs" || subRouteStr == "help-docs") {
val pageIdSegmentIndex = if (destNum != null) 3 else 2
return if (segments.size > pageIdSegmentIndex) {
val pageId = segments[pageIdSegmentIndex]
listOf(SettingsRoute.Settings(destNum), SettingsRoute.HelpDocs, SettingsRoute.HelpDocPage(pageId))
} else {
listOf(SettingsRoute.Settings(destNum), SettingsRoute.HelpDocs)
}
}
val subRoute = settingsSubRoutes[subRouteStr]
return if (subRoute != null) {
listOf(SettingsRoute.Settings(destNum), subRoute)
@@ -210,6 +222,8 @@ object DeepLinkRouter {
"debug-panel" to SettingsRoute.DebugPanel,
"about" to SettingsRoute.About,
"filter-settings" to SettingsRoute.FilterSettings,
"helpdocs" to SettingsRoute.HelpDocs,
"help-docs" to SettingsRoute.HelpDocs,
)
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =

View File

@@ -167,6 +167,14 @@ sealed interface SettingsRoute : Route {
@Serializable data object FilterSettings : SettingsRoute
// endregion
// region help & documentation routes
@Serializable data object HelpDocs : SettingsRoute
@Serializable data class HelpDocPage(val pageId: String) : SettingsRoute
// endregion
}
@Serializable

View File

@@ -326,6 +326,47 @@
<string name="distance_measurements">Distance Measurements</string>
<string name="distance_measurements_description">Display the distance between your phone and other Meshtastic nodes with positions.</string>
<string name="dns">DNS</string>
<!-- DOC -->
<string name="doc_clear_search">Clear search</string>
<string name="doc_keywords_connections">bluetooth,usb,tcp,pairing,serial,wifi</string>
<string name="doc_keywords_desktop">desktop,linux,macos,windows,serial</string>
<string name="doc_keywords_discovery">discovery,topology,network,scan,neighbor</string>
<string name="doc_keywords_firmware">firmware,update,ota,flash,version,recovery</string>
<string name="doc_keywords_map">map,waypoint,gps,position,location,marker</string>
<string name="doc_keywords_measurement">formatter,metric,number,locale,temperature,conversion,api</string>
<string name="doc_keywords_messages">message,channel,encryption,direct,broadcast,quick-chat</string>
<string name="doc_keywords_mqtt">mqtt,broker,internet,bridge,uplink,downlink</string>
<string name="doc_keywords_node_metrics">metrics,telemetry,signal,snr,rssi,battery,traceroute</string>
<string name="doc_keywords_nodes">node,mesh,list,role,status,favorite,filter</string>
<string name="doc_keywords_onboarding">setup,welcome,permissions,first-launch</string>
<string name="doc_keywords_settings_module">module,serial,telemetry,canned,store-forward,administration</string>
<string name="doc_keywords_settings_radio">settings,radio,lora,region,modem,device,power,security</string>
<string name="doc_keywords_signal_meter">signal,rssi,snr,bars,quality,lora,noise,meter</string>
<string name="doc_keywords_tak">tak,atak,cursor-on-target,team-awareness</string>
<string name="doc_keywords_telemetry">telemetry,sensor,temperature,humidity,pressure,power</string>
<string name="doc_keywords_translate">translate,crowdin,localization,language,i18n,contribute</string>
<string name="doc_keywords_units">units,locale,metric,imperial,temperature,distance</string>
<string name="doc_search_placeholder">Search documentation…</string>
<string name="doc_section_developer">Developer Guide</string>
<string name="doc_section_user">User Guide</string>
<string name="doc_title_connections">Connections</string>
<string name="doc_title_desktop">Desktop App</string>
<string name="doc_title_discovery">Discovery</string>
<string name="doc_title_firmware">Firmware Updates</string>
<string name="doc_title_map">Map &amp; Waypoints</string>
<string name="doc_title_measurement">Measurement &amp; Formatting</string>
<string name="doc_title_messages">Messages &amp; Channels</string>
<string name="doc_title_mqtt">MQTT</string>
<string name="doc_title_node_metrics">Node Metrics</string>
<string name="doc_title_nodes">Nodes</string>
<string name="doc_title_onboarding">Getting Started</string>
<string name="doc_title_settings_module">Settings — Modules &amp; Admin</string>
<string name="doc_title_settings_radio">Settings — Radio &amp; User</string>
<string name="doc_title_signal_meter">Signal Meter</string>
<string name="doc_title_tak">TAK Integration</string>
<string name="doc_title_telemetry">Telemetry &amp; Sensors</string>
<string name="doc_title_translate">Translate the App</string>
<string name="doc_title_units">Units &amp; Locale</string>
<string name="done">Done</string>
<string name="dont_show_again_for_device">Don't show again for this device</string>
<string name="double_tap_as_button_press">Double Tap as Button</string>
@@ -507,6 +548,7 @@
<string name="hardware_model">Hardware model</string>
<string name="heading">Heading</string>
<string name="heartbeat">Heartbeat</string>
<string name="help_and_documentation">Help &amp; Documentation</string>
<string name="hide_layer">Hide Layer</string>
<string name="hide_password">Hide password</string>
<string name="history_return_max">History return max</string>

View File

@@ -27,6 +27,7 @@ import org.meshtastic.core.resources.ic_bug_report
import org.meshtastic.core.resources.ic_cleaning_services
import org.meshtastic.core.resources.ic_data_usage
import org.meshtastic.core.resources.ic_format_paint
import org.meshtastic.core.resources.ic_info
import org.meshtastic.core.resources.ic_language
import org.meshtastic.core.resources.ic_list
import org.meshtastic.core.resources.ic_notifications
@@ -70,3 +71,5 @@ val MeshtasticIcons.SettingsRemote: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_settings_remote)
val MeshtasticIcons.Storage: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_storage)
val MeshtasticIcons.HelpOutline: ImageVector
@Composable get() = vectorResource(Res.drawable.ic_info)

View File

@@ -10,3 +10,11 @@ files:
translation: /fastlane/metadata/android/%locale%/%original_file_name%
- source: /fastlane/metadata/android/en-US/changelogs/default.txt
translation: /fastlane/metadata/android/%locale%/changelogs/%original_file_name%
# In-app docs — user guide only (developer guide is English-only)
# Uses %android_code% to output Android/CMP qualifier format directly (pt-rBR, zh-rCN, fr)
- source: /docs/user/*.md
translation: /docs/%android_code%/user/%original_file_name%
type: md
- source: /docs/index.md
translation: /docs/%android_code%/%original_file_name%
type: md

View File

@@ -265,6 +265,7 @@ dependencies {
// Feature modules (JVM variants for real composable wiring)
implementation(projects.feature.settings)
implementation(projects.feature.docs)
implementation(projects.feature.node)
implementation(projects.feature.messaging)
implementation(projects.feature.connections)

View File

@@ -78,6 +78,10 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
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
import org.meshtastic.feature.node.compass.CompassHeadingProvider
import org.meshtastic.feature.node.compass.MagneticFieldProvider
import org.meshtastic.feature.node.compass.PhoneLocationProvider
@@ -96,6 +100,7 @@ import org.meshtastic.core.takserver.di.module as coreTakServerModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.desktop.di.module as desktopDiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
import org.meshtastic.feature.docs.di.module as featureDocsModule
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
import org.meshtastic.feature.intro.di.module as featureIntroModule
import org.meshtastic.feature.map.di.module as featureMapModule
@@ -137,6 +142,7 @@ fun desktopModule() = module {
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
org.meshtastic.feature.docs.di.FeatureDocsModule().featureDocsModule(),
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),
org.meshtastic.desktop.di.DesktopDiModule().desktopDiModule(),
@@ -192,6 +198,10 @@ private fun desktopPlatformStubsModule() = module {
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
// AI assistant: keyword-only fallback on desktop (no on-device model)
single<AIDocAssistant> { get<KeywordFallbackAssistant>() }
single<DocTranslationService> { NoOpDocTranslator() }
// Desktop uses the real ApiService implementation (no flavor stub needed)
single<ApiService> { ApiServiceImpl(client = get()) }

View File

@@ -23,6 +23,7 @@ import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.docs.navigation.docsEntries
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -52,6 +53,7 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(
mapGraph(backStack)
firmwareGraph(backStack)
settingsGraph(backStack)
docsEntries(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
wifiProvisionGraph(backStack)

8
docs/Gemfile Normal file
View File

@@ -0,0 +1,8 @@
source "https://rubygems.org"
gem "jekyll", "~> 4.3"
gem "just-the-docs"
gem "jekyll-redirect-from"
gem "jekyll-remote-theme"
gem "csv"
gem "webrick"

289
docs/_config.yml Normal file
View File

@@ -0,0 +1,289 @@
title: Meshtastic Android
description: "User and developer documentation for the Meshtastic Android, Desktop, and iOS applications."
baseurl: ""
url: ""
# just-the-docs theme
# Local builds use `gem "just-the-docs"` from Gemfile.
# GitHub Pages uses remote_theme for hosted builds.
theme: just-the-docs
remote_theme: just-the-docs/just-the-docs@v0.11.0
# Plugins
plugins:
- jekyll-remote-theme
- jekyll-redirect-from
# Navigation
nav_enabled: true
search_enabled: true
search_tokenizer_separator: /[\s\-/]+/
# Heading anchors (clickable § links)
heading_anchors: true
# Color scheme — loads _sass/color_schemes/meshtastic.scss
color_scheme: meshtastic
# Default front-matter for pages in subdirectories
defaults:
- scope:
path: "user"
values:
parent: User Guide
layout: default
- scope:
path: "developer"
values:
parent: Developer Guide
layout: default
# Locale-translated pages are excluded from main nav but still rendered.
# They use a dedicated locale layout with a back-link to the English version.
# Auto-generated from Android app locales (values-* resource dirs).
- scope:
path: "ar"
values:
layout: locale_page
locale: ar
nav_exclude: true
- scope:
path: "be"
values:
layout: locale_page
locale: be
nav_exclude: true
- scope:
path: "bg"
values:
layout: locale_page
locale: bg
nav_exclude: true
- scope:
path: "ca"
values:
layout: locale_page
locale: ca
nav_exclude: true
- scope:
path: "cs"
values:
layout: locale_page
locale: cs
nav_exclude: true
- scope:
path: "de"
values:
layout: locale_page
locale: de
nav_exclude: true
- scope:
path: "el"
values:
layout: locale_page
locale: el
nav_exclude: true
- scope:
path: "es"
values:
layout: locale_page
locale: es
nav_exclude: true
- scope:
path: "et"
values:
layout: locale_page
locale: et
nav_exclude: true
- scope:
path: "fi"
values:
layout: locale_page
locale: fi
nav_exclude: true
- scope:
path: "fr"
values:
layout: locale_page
locale: fr
nav_exclude: true
- scope:
path: "ga"
values:
layout: locale_page
locale: ga
nav_exclude: true
- scope:
path: "gl"
values:
layout: locale_page
locale: gl
nav_exclude: true
- scope:
path: "he"
values:
layout: locale_page
locale: he
nav_exclude: true
- scope:
path: "hr"
values:
layout: locale_page
locale: hr
nav_exclude: true
- scope:
path: "ht"
values:
layout: locale_page
locale: ht
nav_exclude: true
- scope:
path: "hu"
values:
layout: locale_page
locale: hu
nav_exclude: true
- scope:
path: "is"
values:
layout: locale_page
locale: is
nav_exclude: true
- scope:
path: "it"
values:
layout: locale_page
locale: it
nav_exclude: true
- scope:
path: "ja"
values:
layout: locale_page
locale: ja
nav_exclude: true
- scope:
path: "ko"
values:
layout: locale_page
locale: ko
nav_exclude: true
- scope:
path: "lt"
values:
layout: locale_page
locale: lt
nav_exclude: true
- scope:
path: "nl"
values:
layout: locale_page
locale: nl
nav_exclude: true
- scope:
path: "no"
values:
layout: locale_page
locale: no
nav_exclude: true
- scope:
path: "pl"
values:
layout: locale_page
locale: pl
nav_exclude: true
- scope:
path: "pt"
values:
layout: locale_page
locale: pt
nav_exclude: true
- scope:
path: "pt-rBR"
values:
layout: locale_page
locale: pt-rBR
nav_exclude: true
- scope:
path: "ro"
values:
layout: locale_page
locale: ro
nav_exclude: true
- scope:
path: "ru"
values:
layout: locale_page
locale: ru
nav_exclude: true
- scope:
path: "sk"
values:
layout: locale_page
locale: sk
nav_exclude: true
- scope:
path: "sl"
values:
layout: locale_page
locale: sl
nav_exclude: true
- scope:
path: "sq"
values:
layout: locale_page
locale: sq
nav_exclude: true
- scope:
path: "sr"
values:
layout: locale_page
locale: sr
nav_exclude: true
- scope:
path: "sv"
values:
layout: locale_page
locale: sv
nav_exclude: true
- scope:
path: "tr"
values:
layout: locale_page
locale: tr
nav_exclude: true
- scope:
path: "uk"
values:
layout: locale_page
locale: uk
nav_exclude: true
- scope:
path: "zh-rCN"
values:
layout: locale_page
locale: zh-rCN
nav_exclude: true
- scope:
path: "zh-rTW"
values:
layout: locale_page
locale: zh-rTW
nav_exclude: true
# Callouts (just-the-docs v0.11+)
callouts:
tip:
title: Tip
color: green
note:
title: Note
color: blue
warning:
title: Warning
color: yellow
exclude:
- Gemfile
- Gemfile.lock
- assets/screenshots/.gitkeep
- "*.sh"

119
docs/_data/locales.yml Normal file
View File

@@ -0,0 +1,119 @@
# Supported documentation locales.
# Each entry maps a Crowdin 2-letter code to its native name.
# Pages land at docs/{code}/user/*.md via Crowdin sync.
# Synced with Android app locales (values-* resource dirs).
ar:
name: "العربية"
dir: rtl
be:
name: "Беларуская"
dir: ltr
bg:
name: "Български"
dir: ltr
ca:
name: "Català"
dir: ltr
cs:
name: "Čeština"
dir: ltr
de:
name: "Deutsch"
dir: ltr
el:
name: "Ελληνικά"
dir: ltr
es:
name: "Español"
dir: ltr
et:
name: "Eesti"
dir: ltr
fi:
name: "Suomi"
dir: ltr
fr:
name: "Français"
dir: ltr
ga:
name: "Gaeilge"
dir: ltr
gl:
name: "Galego"
dir: ltr
he:
name: "עברית"
dir: rtl
hr:
name: "Hrvatski"
dir: ltr
ht:
name: "Kreyòl Ayisyen"
dir: ltr
hu:
name: "Magyar"
dir: ltr
is:
name: "Íslenska"
dir: ltr
it:
name: "Italiano"
dir: ltr
ja:
name: "日本語"
dir: ltr
ko:
name: "한국어"
dir: ltr
lt:
name: "Lietuvių"
dir: ltr
nl:
name: "Nederlands"
dir: ltr
"no":
name: "Norsk"
dir: ltr
pl:
name: "Polski"
dir: ltr
pt:
name: "Português"
dir: ltr
pt-rBR:
name: "Português (Brasil)"
dir: ltr
ro:
name: "Română"
dir: ltr
ru:
name: "Русский"
dir: ltr
sk:
name: "Slovenčina"
dir: ltr
sl:
name: "Slovenščina"
dir: ltr
sq:
name: "Shqip"
dir: ltr
sr:
name: "Српски"
dir: ltr
sv:
name: "Svenska"
dir: ltr
tr:
name: "Türkçe"
dir: ltr
uk:
name: "Українська"
dir: ltr
zh-rCN:
name: "中文 (简体)"
dir: ltr
zh-rTW:
name: "中文 (繁體)"
dir: ltr

12
docs/_data/versions.yml Normal file
View File

@@ -0,0 +1,12 @@
versions:
- tag: beta
label: "Beta"
url: /beta/
current: true
# Release entries are added by the docs-release.yml workflow:
# - tag: "v2.8.0"
# label: "2.8.0"
# url: /v2.8.0/
# current: false

View File

@@ -0,0 +1,4 @@
<footer class="site-footer">
Copyright &copy; {{ "now" | date: "%Y" }} Meshtastic LLC. Distributed under the <a href="https://www.gnu.org/licenses/gpl-3.0.html">GPL v3 License.</a>
</footer>

View File

@@ -0,0 +1,166 @@
<!-- Inter font from Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
/* M3-inspired typography */
:root {
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
}
body,
.site-title,
.search-input {
font-family: var(--font-sans) !important;
}
code, pre, .highlight {
font-family: var(--font-mono) !important;
font-size: 0.875em;
}
/* M3 surface elevation & rounding */
.main-content table {
border-radius: 12px;
overflow: hidden;
}
.main-content blockquote {
border-radius: 12px;
padding: 1rem 1.25rem;
border-left-width: 4px;
}
.main-content pre {
border-radius: 12px;
}
.main-content code {
border-radius: 6px;
padding: 0.15em 0.4em;
}
/* Smooth transitions for theme switching */
body,
.side-bar,
.main,
.main-content,
.site-header,
.search-input,
.search-results,
a {
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
/* Theme toggle button styling */
.theme-toggle {
background: none;
border: 1px solid var(--border-color, #D5D6E0);
border-radius: 20px;
padding: 6px 12px;
cursor: pointer;
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 6px;
color: inherit;
margin: 0 auto;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.theme-toggle:hover {
background-color: rgba(128, 128, 128, 0.1);
}
.theme-toggle-wrap {
text-align: center;
padding: 12px 0 4px;
}
/* Heading weight refinement */
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
letter-spacing: -0.01em;
}
h1 { font-weight: 700; letter-spacing: -0.02em; }
/* Smooth anchor scroll */
html { scroll-behavior: smooth; }
/* Language switcher */
.language-switcher {
display: inline-block;
position: relative;
margin-left: 8px;
}
.language-switcher-btn {
cursor: pointer;
font-size: 0.8rem;
font-weight: 500;
font-family: var(--font-sans);
list-style: none;
padding: 4px 10px;
border: 1px solid var(--border-color, #D5D6E0);
border-radius: 16px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: background-color 0.2s ease;
}
.language-switcher-btn:hover {
background-color: rgba(128, 128, 128, 0.1);
}
.language-switcher[open] .language-switcher-list {
display: block;
}
.language-switcher-list {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
list-style: none;
padding: 8px 0;
margin: 4px 0 0;
min-width: 140px;
background: var(--body-background-color, #fff);
border: 1px solid var(--border-color, #D5D6E0);
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.language-switcher-list li {
padding: 0;
}
.language-switcher-list a {
display: block;
padding: 6px 16px;
text-decoration: none;
font-size: 0.85rem;
color: inherit;
}
.language-switcher-list a:hover {
background-color: rgba(103, 234, 148, 0.15);
}
</style>
<script>
// Respect OS preference on first visit, then remember user choice
(function() {
var stored = localStorage.getItem('jtd-theme');
if (stored) return; // will be applied by jtd.setTheme below
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
localStorage.setItem('jtd-theme', 'meshtastic-dark');
}
})();
</script>

View File

@@ -0,0 +1,45 @@
<div class="theme-toggle-wrap">
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" title="Toggle light/dark theme" onclick="toggleMeshtasticTheme()">
<span id="theme-icon">🌙</span>
<span id="theme-label">Dark</span>
</button>
{% include language_switcher.html %}
</div>
<script>
function toggleMeshtasticTheme() {
var current = localStorage.getItem('jtd-theme') || 'meshtastic';
var next = (current === 'meshtastic-dark') ? 'meshtastic' : 'meshtastic-dark';
if (typeof jtd !== 'undefined' && typeof jtd.setTheme === 'function') {
jtd.setTheme(next);
}
localStorage.setItem('jtd-theme', next);
var icon = document.getElementById('theme-icon');
var label = document.getElementById('theme-label');
if (icon && label) {
icon.textContent = (next === 'meshtastic-dark') ? '☀️' : '🌙';
label.textContent = (next === 'meshtastic-dark') ? 'Light' : 'Dark';
}
}
// Apply stored/OS theme on page load
(function() {
var theme = localStorage.getItem('jtd-theme') || 'meshtastic';
// Sync toggle label immediately
var icon = document.getElementById('theme-icon');
var label = document.getElementById('theme-label');
if (icon && label) {
icon.textContent = (theme === 'meshtastic-dark') ? '☀️' : '🌙';
label.textContent = (theme === 'meshtastic-dark') ? 'Light' : 'Dark';
}
// Apply theme once jtd is ready
function tryApply() {
if (typeof jtd !== 'undefined' && typeof jtd.setTheme === 'function') {
jtd.setTheme(theme);
} else {
setTimeout(tryApply, 50);
}
}
tryApply();
})();
</script>

View File

@@ -0,0 +1,71 @@
{% comment %}
Language switcher for translated docs pages.
Renders a dropdown-style link list showing available translations of the current page.
Include this in any page or layout that should offer locale switching.
Usage: {% include language_switcher.html %}
Logic:
- Derives the current page's relative path within its section
- Checks if translated versions exist in locale subdirectories
- Shows a globe icon with available locale links
{% endcomment %}
{% assign current_path = page.path %}
{% assign locales = site.data.locales %}
{% if locales and current_path %}
<details class="language-switcher" aria-label="Language options">
<summary class="language-switcher-btn" title="View in another language">
🌐 <span class="lang-current">English</span>
</summary>
<ul class="language-switcher-list">
{% comment %} Always show English link back to source {% endcomment %}
{% assign path_parts = current_path | split: "/" %}
{% assign first_segment = path_parts[0] %}
{% comment %} Detect if we're currently IN a locale subdir {% endcomment %}
{% if locales[first_segment] %}
{% comment %} We're on a translated page — link back to English {% endcomment %}
{% assign remaining_parts = path_parts | slice: 1, path_parts.size %}
{% assign en_path = remaining_parts | join: "/" | replace: ".md", "" %}
<li><a href="{{ en_path | relative_url }}" lang="en">English</a></li>
{% endif %}
{% comment %} Show all available locale versions {% endcomment %}
{% for locale in locales %}
{% assign locale_code = locale[0] %}
{% assign locale_info = locale[1] %}
{% if locales[first_segment] %}
{% comment %} We're already on a translated page {% endcomment %}
{% if locale_code == first_segment %}
{% continue %}
{% endif %}
{% assign locale_path = locale_code | append: "/" | append: en_path %}
{% else %}
{% comment %} We're on an English page {% endcomment %}
{% assign en_relative = current_path | replace: ".md", "" %}
{% assign locale_path = locale_code | append: "/" | append: en_relative %}
{% endif %}
{% comment %}
Check if the translated file actually exists.
Jekyll doesn't have a file_exists filter, so we check site.pages.
{% endcomment %}
{% assign locale_file = locale_path | append: ".md" %}
{% assign page_exists = false %}
{% for p in site.pages %}
{% if p.path == locale_file %}
{% assign page_exists = true %}
{% break %}
{% endif %}
{% endfor %}
{% if page_exists %}
<li><a href="{{ locale_path | relative_url }}" lang="{{ locale_code }}" {% if locale_info.dir == "rtl" %}dir="rtl"{% endif %}>{{ locale_info.name }}</a></li>
{% endif %}
{% endfor %}
</ul>
</details>
{% endif %}

View File

@@ -0,0 +1,36 @@
---
layout: default
---
{% assign locale_code = page.locale | default: "en" %}
{% assign locale_info = site.data.locales[locale_code] %}
{% assign page_path = page.path %}
{% assign path_parts = page_path | split: "/" %}
{% assign remaining_parts = path_parts | slice: 1, path_parts.size %}
{% assign en_path = remaining_parts | join: "/" | replace: ".md", "" %}
<div class="locale-page-banner" {% if locale_info.dir == "rtl" %}dir="rtl"{% endif %}>
<p class="locale-notice">
🌐 <strong>{{ locale_info.name }}</strong> — Community translation
<a href="{{ en_path | relative_url }}">View in English</a>
</p>
</div>
<div {% if locale_info.dir == "rtl" %}dir="rtl" lang="{{ locale_code }}"{% else %}lang="{{ locale_code }}"{% endif %}>
{{ content }}
</div>
<style>
.locale-page-banner {
background: rgba(103, 234, 148, 0.1);
border: 1px solid rgba(103, 234, 148, 0.3);
border-radius: 12px;
padding: 10px 16px;
margin-bottom: 1.5rem;
font-size: 0.85rem;
}
.locale-notice a {
margin-left: 12px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,40 @@
// Meshtastic Material 3 — Dark color scheme for just-the-docs
// Source: core/ui/.../Color.kt §8.3 (Dark Scheme)
//
// Brand colors:
// Primary (Dark): #67EA94 (Green 500)
// Surface (Dark): #1A1B26 (Neutral 900)
$color-scheme: dark;
// Body / page background
$body-background-color: #1A1B26; // Neutral 900 — surfaceDark
$sidebar-color: #1A1B26; // Neutral 900
$body-text-color: #ECEDF3; // Neutral 100 — onSurfaceDark
$body-heading-color: #ECEDF3; // Neutral 100
// Links
$link-color: #67EA94; // Green 500 — primaryDark
$nav-link-color: #67EA94; // Green 500
// Navigation
$nav-child-link-color: #B8BAC8; // Neutral 300 — secondaryDark
$search-result-preview-color: #B8BAC8;
// Code
$code-background-color: #0F1017; // Neutral 950 — surfaceContainerLowestDark
$code-linenumber-color: #9496A6; // Neutral 400
// Borders
$border-color: #3D3E50; // Neutral 700 — outlineVariantDark
$table-background-color: #242533; // Interpolated — surfaceContainerDark
// Buttons
$btn-primary-color: #67EA94; // Green 500
$base-button-color: #2C2D3C; // Neutral 800
// Feedback / callouts
$feedback-color: #242533; // surfaceContainerDark
// Search
$search-background-color: #242533; // surfaceContainerDark

View File

@@ -0,0 +1,39 @@
// Meshtastic Material 3 — Light color scheme for just-the-docs
// Source: core/ui/.../Color.kt §8.2 (Light Scheme)
//
// Brand colors:
// Primary (Light): #2D8F52 (Green 700)
// Surface (Light): #F5F6FA (Neutral 50)
$color-scheme: light;
// Body / page background
$body-background-color: #F5F6FA; // Neutral 50 — surfaceLight
$sidebar-color: #ECEDF3; // Neutral 100 — surfaceContainerLight
$body-text-color: #2C2D3C; // Neutral 800 — onSurfaceLight
$body-heading-color: #2C2D3C; // Neutral 800
// Links
$link-color: #2D8F52; // Green 700 — primaryLight
$nav-link-color: #2D8F52; // Green 700
// Navigation
$nav-child-link-color: #555668; // Neutral 600 — secondaryLight
$search-result-preview-color: #555668;
// Code
$code-background-color: #ECEDF3; // Neutral 100
$code-linenumber-color: #9496A6; // Neutral 400
// Borders
$border-color: #D5D6E0; // Neutral 200 — outlineVariantLight
$table-background-color: #F5F6FA; // Neutral 50
// Buttons
$btn-primary-color: #2D8F52; // Green 700
// Feedback / callouts
$feedback-color: #E0E1EB; // surfaceContainerHighLight
// Search
$search-background-color: #FFFFFF; // surfaceContainerLowestLight

View File

@@ -0,0 +1,3 @@
---
---
{% include css/just-the-docs.scss.liquid color_scheme="meshtastic-dark" %}

View File

@@ -0,0 +1,3 @@
---
---
{% include css/just-the-docs.scss.liquid color_scheme="meshtastic" %}

View File

@@ -0,0 +1,41 @@
# Screenshots
This directory contains screenshot assets referenced by the documentation pages.
Screenshots are sourced from the Compose Preview Screenshot Testing reference images
in `screenshot-tests/src/screenshotTestDebug/reference/`. Light-mode variants are
copied here for use by the Jekyll docs site and in-app documentation browser.
## Updating Screenshots
After changing a UI component, regenerate reference images and copy them here:
```bash
./gradlew :screenshot-tests:updateDebugScreenshotTest
```
Then copy the relevant light-mode PNGs from the reference directory. The
`copyDocsScreenshots` task automates bulk copying based on the manifest:
```bash
./gradlew :screenshot-tests:copyDocsScreenshots
```
## Naming Convention
```
{page-id}_{description}.png
```
Examples:
- `onboarding_welcome.png`
- `connections_bluetooth_scan.png`
- `messages-and-channels_channel_list.png`
- `firmware_disclaimer.png`
## Guidelines
- PNG format, light-mode only (dark variants live in reference directory)
- Name screenshots to match the docs page they appear in
- Keep filenames lowercase with underscores

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

49
docs/developer.md Normal file
View File

@@ -0,0 +1,49 @@
---
title: Developer Guide
layout: default
nav_order: 2
has_children: true
parent: ""
---
# Developer Guide
Technical documentation for contributing to the Meshtastic Android and Desktop app.
---
## Before You Open a PR
Things that trip up first-time contributors — check these before requesting review:
- **Formatting passes** — run `./gradlew spotlessApply` to auto-format, then verify with `spotlessCheck`
- **Detekt passes** — run `./gradlew detekt` and fix all reported issues
- **All tests pass** — run `./gradlew test allTests` (both are needed: `test` covers Android-only modules, `allTests` covers KMP)
- **Screenshot tests pass** — if you touched any Compose UI, run `./gradlew :screenshot-tests:validateFdroidDebugScreenshotTest` and update reference images if needed
- **Proto submodule unchanged** — `core/proto/` is a read-only git submodule. Never modify proto files directly
- **Docs updated** — if you changed user-visible UI, update the corresponding page under `docs/user/`. The `UI & Docs Governance` CI workflow will flag the PR if you didn't. Add the `skip-docs-check` label if it genuinely isn't needed
- **Previews updated** — if you changed UI composables, update the corresponding `*Previews.kt` file and screenshot tests. The governance workflow will post an advisory. Add `skip-preview-check` to dismiss
- **Branch naming** — branches must start with `feat/`, `fix/`, `chore/`, `docs/`, `build/`, `ci/`, `refactor/`, `test/`, or `deps/`
---
## What's New for Developers
<!-- DEV_WHATS_NEW_START -->
<!-- Add new entries at the top. Format:
**Month YYYY** — [Page or area](relative/path) — One sentence on what changed architecturally or procedurally.
Keep the last 58 entries and trim older ones from the bottom.
-->
**May 2026** — [Measurement & Formatting](developer/measurement) — New page documenting the `MetricFormatter` API, locale-aware unit conversion patterns, and how to add new measurement types.
**May 2026** — [Testing](developer/testing) — Compose Preview Screenshot Testing (CST) integrated: `screenshot-tests/` module, `@PreviewTest` wrappers, CI validation, docs asset pipeline.
**May 2026** — In-app documentation system added: markdown source under `docs/user/` and `docs/developer/` is bundled as Compose Resources and rendered via `multiplatform-markdown-renderer-m3`.
**May 2026** — [Architecture](developer/architecture) — Documented KMP module layering, Navigation 3 patterns, and feature module conventions.
**May 2026** — [Contributing](developer/contributing) — Established docs governance CI workflow for PRs that change UI without updating docs.
<!-- DEV_WHATS_NEW_END -->

View File

@@ -0,0 +1,147 @@
---
title: Adding a Feature Module
nav_order: 3
last_updated: 2026-05-13
aliases:
- new-module
- feature-module
- module-guide
---
# Adding a Feature Module
Step-by-step guide for creating a new KMP feature module in the Meshtastic project.
## 1. Create the Module Directory
```bash
mkdir -p feature/my-feature/src/{commonMain,commonTest,androidMain,jvmMain,iosMain}/kotlin/org/meshtastic/feature/myfeature
```
## 2. Create `build.gradle.kts`
```kotlin
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
alias(libs.plugins.meshtastic.kotlinx.serialization)
id("meshtastic.kmp.jvm.android")
}
kotlin {
android {
namespace = "org.meshtastic.feature.myfeature"
androidResources.enable = false
}
sourceSets {
commonMain.dependencies {
implementation(projects.core.common)
implementation(projects.core.navigation)
implementation(projects.core.resources)
implementation(projects.core.ui)
implementation(projects.core.di)
}
commonTest.dependencies {
implementation(libs.compose.multiplatform.ui.test)
}
jvmTest.dependencies {
implementation(compose.desktop.currentOs)
}
}
}
```
## 3. Register in `settings.gradle.kts`
Add your module to the main `include()` block:
```kotlin
include(
// ...existing modules...
":feature:my-feature",
)
```
## 4. Create the DI Module
`src/commonMain/kotlin/org/meshtastic/feature/myfeature/di/FeatureMyFeatureModule.kt`:
```kotlin
package org.meshtastic.feature.myfeature.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.myfeature")
class FeatureMyFeatureModule
```
## 5. Register DI in App/Desktop
Add your module to:
- `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
- `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`
## 6. Add Navigation Routes
In `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`:
```kotlin
@Serializable
sealed interface MyFeatureRoute : Route {
@Serializable data object MyFeatureGraph : MyFeatureRoute, Graph
@Serializable data object MyFeatureHome : MyFeatureRoute
}
```
## 7. Create Navigation Entries
`src/commonMain/kotlin/org/meshtastic/feature/myfeature/navigation/MyFeatureNavigation.kt`:
```kotlin
package org.meshtastic.feature.myfeature.navigation
import androidx.navigation3.runtime.EntryProviderScope
import org.meshtastic.core.navigation.MyFeatureRoute
fun EntryProviderScope<*>.myFeatureEntries() {
entry<MyFeatureRoute.MyFeatureHome> {
MyFeatureScreen()
}
}
```
## 8. Source Set Guidelines
| Source Set | Contains |
|-----------|----------|
| `commonMain` | Models, ViewModels, shared UI, DI module, navigation |
| `androidMain` | Android-specific implementations (e.g., platform APIs) |
| `jvmMain` | Desktop-specific implementations |
| `iosMain` | iOS-specific implementations |
| `commonTest` | Shared unit tests |
## 9. Testing Expectations
Every feature module should have:
- Unit tests in `commonTest` for business logic
- UI tests using `compose-multiplatform-ui-test` where appropriate
- No test dependency on other feature modules
## 10. Checklist
- [ ] Module directory created
- [ ] `build.gradle.kts` with correct plugins and dependencies
- [ ] Added to `settings.gradle.kts`
- [ ] DI module created with `@ComponentScan`
- [ ] DI module registered in app and desktop roots
- [ ] Routes added to `Routes.kt`
- [ ] Navigation entries registered
- [ ] `./gradlew kmpSmokeCompile` passes
- [ ] `./gradlew :feature:my-feature:allTests` passes
---

View File

@@ -0,0 +1,132 @@
---
title: Architecture
nav_order: 1
last_updated: 2026-05-13
aliases:
- layers
- module-architecture
- kmp
---
# Architecture
The Meshtastic Android/Desktop/iOS application follows a modular Kotlin Multiplatform (KMP) architecture with clear layer boundaries.
## Layer Overview
```
┌─────────────────────────────────────────────┐
│ app / desktop │ Platform entry points
├─────────────────────────────────────────────┤
│ feature/* modules │ UI + Business Logic
├─────────────────────────────────────────────┤
│ core/* modules │ Shared infrastructure
├─────────────────────────────────────────────┤
│ Platform (Android/JVM/iOS) │ OS-specific bindings
└─────────────────────────────────────────────┘
```
## Module Categories
### `app/` — Android Application
The Android application entry point:
- Activity, Application, and Manifest definitions
- Koin DI module composition (`AppKoinModule`)
- Flavor-specific bindings (`google/`, `fdroid/`)
- Android-only integrations (widgets, services)
### `desktop/` — Desktop JVM Application
The Desktop (Linux/macOS/Windows) entry point:
- Compose Desktop window management
- Desktop-specific DI (`DesktopKoinModule`)
- Platform stubs for Android-only capabilities
- Serial transport implementation
### `feature/*` — Feature Modules
Each `feature/` module owns a vertical slice of functionality:
| Module | Responsibility |
|--------|---------------|
| `feature:intro` | Onboarding/welcome flow |
| `feature:messaging` | Messages, channels, contacts, quick chat |
| `feature:connections` | Bluetooth/USB/TCP connection management |
| `feature:map` | Map display, waypoints |
| `feature:node` | Node list, node detail, metrics |
| `feature:settings` | All configuration screens |
| `feature:firmware` | Firmware update flow |
| `feature:docs` | In-app documentation browser |
| `feature:wifi-provision` | WiFi provisioning |
| `feature:widget` | Android home screen widgets |
Feature modules:
- Use the `meshtastic.kmp.feature` convention plugin
- Depend on `core` modules, never on other `feature` modules
- Own their navigation entries and DI registrations
- Contain platform-specific implementations in `androidMain`/`jvmMain`/`iosMain`
### `core/*` — Core Modules
Shared infrastructure used by all features:
| Module | Responsibility |
|--------|---------------|
| `core:common` | Utilities, extensions, build config |
| `core:navigation` | Routes, deep links, Navigation 3 |
| `core:ui` | Shared Compose components, icons, theme |
| `core:resources` | Shared string resources |
| `core:model` | Domain models |
| `core:data` | Data layer abstractions |
| `core:database` | Room KMP database |
| `core:datastore` | DataStore preferences |
| `core:prefs` | App preferences |
| `core:repository` | Repository interfaces |
| `core:service` | Mesh service layer |
| `core:di` | DI utilities |
| `core:network` | HTTP/serial/transport |
| `core:ble` | Bluetooth LE abstractions |
| `core:proto` | Protobuf definitions |
| `core:testing` | Test utilities |
## KMP Source Sets
Each module uses the standard KMP source set hierarchy:
```
src/
├── commonMain/ ← Shared code (all platforms)
├── commonTest/ ← Shared tests
├── androidMain/ ← Android-specific
├── jvmMain/ ← Desktop JVM-specific
├── iosMain/ ← iOS-specific
└── jvmTest/ ← Desktop test host
```
**Golden Rules:**
- No `android.*` imports in `commonMain`
- Platform-specific code goes in appropriate source set
- Prefer interfaces + DI over `expect`/`actual` for complex behaviors
- Use `expect`/`actual` only for simple declarations
## Dependency Injection
The project uses **Koin** with annotation processing:
- `@Module`, `@Single`, `@Factory` annotations
- `@ComponentScan` for automatic registration
- Feature modules export their own `Feature*Module` class
- App/Desktop compose all modules in their root DI configuration
## Navigation
Navigation uses **Navigation 3** with typed routes:
- All routes defined in `core/navigation/Routes.kt`
- Routes are `@Serializable` data classes/objects
- Deep links resolved through `DeepLinkRouter`
- Each feature registers its own navigation entries
See [Navigation & Deep Links](navigation-and-deep-links) for details.
---

137
docs/developer/codebase.md Normal file
View File

@@ -0,0 +1,137 @@
---
title: Codebase
nav_order: 2
last_updated: 2026-05-13
aliases:
- repository-layout
- project-structure
- source-code
---
# Codebase
Repository layout, namespacing conventions, and build system overview.
## Repository Structure
```
Meshtastic-Android/
├── app/ # Android application module
│ ├── src/main/ # Shared Android code
│ ├── src/google/ # Google Play flavor (Gemini, proprietary)
│ └── src/fdroid/ # F-Droid flavor (FOSS-only)
├── desktop/ # Desktop JVM application
├── feature/ # Feature modules (KMP)
│ ├── intro/
│ ├── messaging/
│ ├── connections/
│ ├── map/
│ ├── node/
│ ├── settings/
│ ├── firmware/
│ ├── docs/
│ ├── wifi-provision/
│ └── widget/
├── core/ # Core infrastructure modules (KMP)
│ ├── common/
│ ├── navigation/
│ ├── ui/
│ ├── resources/
│ ├── model/
│ ├── data/
│ ├── database/
│ ├── datastore/
│ ├── prefs/
│ ├── repository/
│ ├── service/
│ ├── di/
│ ├── network/
│ ├── ble/
│ ├── proto/
│ └── testing/
├── build-logic/ # Convention plugins and build helpers
│ └── convention/
├── docs/ # Documentation source (markdown)
│ ├── user/
│ └── developer/
├── gradle/ # Gradle wrapper and version catalog
│ └── libs.versions.toml
├── specs/ # Feature specifications
└── .github/workflows/ # CI/CD workflows
```
## Namespacing Convention
All Kotlin packages follow the pattern:
```
org.meshtastic.{layer}.{module}.{subpackage}
```
Examples:
- `org.meshtastic.core.navigation` — core navigation module
- `org.meshtastic.feature.docs.ui` — docs feature UI package
- `org.meshtastic.app.di` — app DI configuration
## Build System
### Gradle Kotlin DSL
All build files use Kotlin DSL (`.gradle.kts`). Configuration:
- **Version catalog:** `gradle/libs.versions.toml`
- **Convention plugins:** `build-logic/convention/`
- **Settings:** `settings.gradle.kts`
### Convention Plugins
Located in `build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/`:
| Plugin | Purpose |
|--------|---------|
| `meshtastic.kmp.feature` | Standard feature module setup |
| `meshtastic.kmp.jvm.android` | JVM + Android target configuration |
| `meshtastic.kotlinx.serialization` | Serialization plugin setup |
### Build Variants (Android)
| Flavor | Description |
|--------|-------------|
| `google` | Google Play distribution; includes proprietary APIs |
| `fdroid` | F-Droid distribution; FOSS-only dependencies |
### Key Gradle Tasks
```bash
# Compile check across all KMP targets
./gradlew kmpSmokeCompile
# Run all tests
./gradlew allTests
# Code quality
./gradlew spotlessCheck detekt
# Android build
./gradlew assembleGoogleDebug assembleFdroidDebug
# Desktop run
./gradlew :desktop:run
```
## Version Catalog Highlights
Key dependencies in `gradle/libs.versions.toml`:
| Category | Library |
|----------|---------|
| Compose | Compose Multiplatform (JetBrains) |
| Navigation | Navigation 3 |
| DI | Koin (annotations) |
| Serialization | kotlinx.serialization |
| Database | Room KMP |
| Networking | Ktor |
| Markdown | multiplatform-markdown-renderer |
| Testing | kotlin-test, compose-ui-test |
---

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