diff --git a/.agent_memory/session_context.md b/.agent_memory/session_context.md index f1a5b4ebc..3d98a8c99 100644 --- a/.agent_memory/session_context.md +++ b/.agent_memory/session_context.md @@ -3,6 +3,21 @@ # Do NOT edit or remove previous entries — stale state claims cause agent confusion. # Format: ## YYYY-MM-DD — +## 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. diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 000000000..350fd04a1 --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -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 + diff --git a/.github/workflows/docs-governance.yml b/.github/workflows/docs-governance.yml new file mode 100644 index 000000000..71489a8e7 --- /dev/null +++ b/.github/workflows/docs-governance.yml @@ -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<> "$GITHUB_OUTPUT" + echo "$views_changed" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + echo "docs_changed<> "$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<> "$GITHUB_OUTPUT" + echo "$ui_changed" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + echo "preview_changed<> "$GITHUB_OUTPUT" + echo "$preview_changed" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + echo "screenshot_tests_changed<> "$GITHUB_OUTPUT" + echo "$screenshot_tests_changed" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + + echo "refs_changed<> "$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.`, + }); + } + } diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml new file mode 100644 index 000000000..7331d9e53 --- /dev/null +++ b/.github/workflows/docs-release.yml @@ -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 < /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 + diff --git a/.github/workflows/scheduled-updates.yml b/.github/workflows/scheduled-updates.yml index 216d05c4e..63e1b9f5b 100644 --- a/.github/workflows/scheduled-updates.yml +++ b/.github/workflows/scheduled-updates.yml @@ -177,6 +177,7 @@ jobs: fastlane/metadata/android/** **/strings.xml **/README.md + docs/**/*.md labels: | automation l10n diff --git a/.github/workflows/sync-android-docs.yml b/.github/workflows/sync-android-docs.yml new file mode 100644 index 000000000..4d56c12fc --- /dev/null +++ b/.github/workflows/sync-android-docs.yml @@ -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. diff --git a/.gitignore b/.gitignore index ce8a34ea7..77738d14b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +/.specify/extensions/.cache/ +# Jekyll local config (comments out remote_theme for local builds) +docs/_config_local.yml diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 71c89ac96..c900b3eef 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -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 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index fdf770bd5..e7b45102c 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -1,19 +1,17 @@ # 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 diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 8581f519f..480989d8a 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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") } diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt new file mode 100644 index 000000000..53edf9aca --- /dev/null +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FdroidAiModule.kt @@ -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 . + */ +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() +} diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt index 5a192d437..6e797e952 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt new file mode 100644 index 000000000..c6848b5e0 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/GeminiNanoDocAssistant.kt @@ -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 . + */ +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 Firebase AI Logic Hybrid + */ +@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, val usedPageIds: Set, val totalChars: Int) + + /** Builds context parts from ranked pages within the given char budget. */ + private fun buildContext( + currentPageId: String?, + queryTerms: List, + rankedPages: List>, + allContent: Map, + budget: Int, + ): ContextResult { + val usedPageIds = mutableSetOf() + val contextParts = mutableListOf() + 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 = 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, + 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, + pages: List, + allContent: Map, + ): List> = 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 { + 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" + } +} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt index 802f3b150..20fe0bff6 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt @@ -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 diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt new file mode 100644 index 000000000..12e623599 --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/di/GoogleAiModule.kt @@ -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 . + */ +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) +} diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitDocTranslator.kt b/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitDocTranslator.kt new file mode 100644 index 000000000..5ac72334e --- /dev/null +++ b/androidApp/src/google/kotlin/org/meshtastic/app/translation/MlKitDocTranslator.kt @@ -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 . + */ +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 + } +} diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt index 09f38eaef..36b7a242a 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt @@ -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, diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 9177c7edb..ec8dab03e 100644 --- a/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/androidApp/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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) } diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt index fd4b7aba8..0aa9d2f41 100644 --- a/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt +++ b/androidApp/src/test/kotlin/org/meshtastic/app/di/KoinVerificationTest.kt @@ -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( diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 09c00abb7..87fa2f3d0 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -194,6 +194,11 @@ gradlePlugin { implementationClass = "RootConventionPlugin" } + register("docs") { + id = "meshtastic.docs" + implementationClass = "org.meshtastic.buildlogic.DocsTasks" + } + register("publishing") { id = "meshtastic.publishing" implementationClass = "PublishingConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt index 135306af6..140f3eaef 100644 --- a/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/RootConventionPlugin.kt @@ -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( diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidInstrumentedTests.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidInstrumentedTests.kt index 5b37c9765..168aeed1e 100644 --- a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidInstrumentedTests.kt +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/AndroidInstrumentedTests.kt @@ -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() + ) } diff --git a/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt new file mode 100644 index 000000000..450b05e3b --- /dev/null +++ b/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt @@ -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 . + */ +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 { + override fun apply(project: Project) { + val docsDir = project.rootProject.layout.projectDirectory.dir("docs") + val outputDir = project.layout.buildDirectory.dir("generated/docs") + + project.tasks.register("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("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("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 + + @get:Input + abstract val version: Property + + @TaskAction + fun generate() { + val src = sourceDir.get().asFile + val out = generatedOutputDir.get().asFile + out.mkdirs() + + val indexEntries = mutableListOf() + 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 { + 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() + 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 = + raw.split(",").map { it.trim() }.filter { it.isNotEmpty() } + + private fun extractKeywords(file: File, title: String): List { + val text = file.readText().lowercase() + val keywords = mutableSetOf() + // 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("&", "&").replace("<", "<").replace(">", ">") + 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 """ + | + | + | + | + | + | $title + | + | + | + |
$content
+ | + | + """.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 + + @get:Input + abstract val version: Property + + @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)") + } +} + + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 79f18eeec..8a6dfdaf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 { diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt index 77b83e42c..d610af483 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt @@ -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() diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt index 531df0459..194478f18 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt @@ -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 diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 7556105b3..621d52093 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -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 diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt index d6092de3f..66f7dd07e 100644 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt @@ -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 { diff --git a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/DatabaseManagerLegacyCleanupTest.kt b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/DatabaseManagerLegacyCleanupTest.kt index 4aa88fc76..68dbb176c 100644 --- a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/DatabaseManagerLegacyCleanupTest.kt +++ b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/DatabaseManagerLegacyCleanupTest.kt @@ -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() - 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() } } diff --git a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseTest.kt b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseTest.kt index 1a918732e..4f4edeefe 100644 --- a/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseTest.kt +++ b/core/database/src/androidDeviceTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseTest.kt @@ -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() } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt index f88dabc60..9335b6a54 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt @@ -132,6 +132,7 @@ object DeepLinkRouter { } } + @Suppress("ReturnCount", "MagicNumber") private fun routeSettings(segments: List): List { 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 Route> = diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index a0df53eb3..9c140181a 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -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 diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index ff6c6333b..26d831fe6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -326,6 +326,47 @@ Distance Measurements Display the distance between your phone and other Meshtastic nodes with positions. DNS + + Clear search + bluetooth,usb,tcp,pairing,serial,wifi + desktop,linux,macos,windows,serial + discovery,topology,network,scan,neighbor + firmware,update,ota,flash,version,recovery + map,waypoint,gps,position,location,marker + formatter,metric,number,locale,temperature,conversion,api + message,channel,encryption,direct,broadcast,quick-chat + mqtt,broker,internet,bridge,uplink,downlink + metrics,telemetry,signal,snr,rssi,battery,traceroute + node,mesh,list,role,status,favorite,filter + setup,welcome,permissions,first-launch + module,serial,telemetry,canned,store-forward,administration + settings,radio,lora,region,modem,device,power,security + signal,rssi,snr,bars,quality,lora,noise,meter + tak,atak,cursor-on-target,team-awareness + telemetry,sensor,temperature,humidity,pressure,power + translate,crowdin,localization,language,i18n,contribute + units,locale,metric,imperial,temperature,distance + Search documentation… + Developer Guide + User Guide + Connections + Desktop App + Discovery + Firmware Updates + Map & Waypoints + Measurement & Formatting + Messages & Channels + MQTT + Node Metrics + Nodes + Getting Started + Settings — Modules & Admin + Settings — Radio & User + Signal Meter + TAK Integration + Telemetry & Sensors + Translate the App + Units & Locale Done Don't show again for this device Double Tap as Button @@ -507,6 +548,7 @@ Hardware model Heading Heartbeat + Help & Documentation Hide Layer Hide password History return max diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt index 5c905aab6..124ad5db8 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Settings.kt @@ -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) diff --git a/crowdin.yml b/crowdin.yml index f37d09a27..64a130ca7 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -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 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 42b8c241e..1f099dc97 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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) diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 59f468f82..261abeeae 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -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 { NoopPhoneLocationProvider() } single { NoopMagneticFieldProvider() } + // AI assistant: keyword-only fallback on desktop (no on-device model) + single { get() } + single { NoOpDocTranslator() } + // Desktop uses the real ApiService implementation (no flavor stub needed) single { ApiServiceImpl(client = get()) } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index d7581cc9c..83494ce00 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -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.desktopNavGraph( mapGraph(backStack) firmwareGraph(backStack) settingsGraph(backStack) + docsEntries(backStack) channelsGraph(backStack) connectionsGraph(backStack) wifiProvisionGraph(backStack) diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 000000000..9248b3181 --- /dev/null +++ b/docs/Gemfile @@ -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" diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 000000000..4b76bae58 --- /dev/null +++ b/docs/_config.yml @@ -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" + diff --git a/docs/_data/locales.yml b/docs/_data/locales.yml new file mode 100644 index 000000000..a40998a1c --- /dev/null +++ b/docs/_data/locales.yml @@ -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 diff --git a/docs/_data/versions.yml b/docs/_data/versions.yml new file mode 100644 index 000000000..a5324ae93 --- /dev/null +++ b/docs/_data/versions.yml @@ -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 + diff --git a/docs/_includes/footer_custom.html b/docs/_includes/footer_custom.html new file mode 100644 index 000000000..80918c95e --- /dev/null +++ b/docs/_includes/footer_custom.html @@ -0,0 +1,4 @@ +
+ Copyright © {{ "now" | date: "%Y" }} Meshtastic LLC. Distributed under the GPL v3 License. +
+ diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html new file mode 100644 index 000000000..fe012543d --- /dev/null +++ b/docs/_includes/head_custom.html @@ -0,0 +1,166 @@ + + + + + + + + diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html new file mode 100644 index 000000000..b19cdf3bc --- /dev/null +++ b/docs/_includes/header_custom.html @@ -0,0 +1,45 @@ +
+ + {% include language_switcher.html %} +
+ + diff --git a/docs/_includes/language_switcher.html b/docs/_includes/language_switcher.html new file mode 100644 index 000000000..1b0cf4238 --- /dev/null +++ b/docs/_includes/language_switcher.html @@ -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 %} +
+ + 🌐 English + +
    + {% 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", "" %} +
  • English
  • + {% 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 %} +
  • {{ locale_info.name }}
  • + {% endif %} + {% endfor %} +
+
+{% endif %} diff --git a/docs/_layouts/locale_page.html b/docs/_layouts/locale_page.html new file mode 100644 index 000000000..167e80b09 --- /dev/null +++ b/docs/_layouts/locale_page.html @@ -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", "" %} + +
+

+ 🌐 {{ locale_info.name }} — Community translation + View in English +

+
+ +
+{{ content }} +
+ + diff --git a/docs/_sass/color_schemes/meshtastic-dark.scss b/docs/_sass/color_schemes/meshtastic-dark.scss new file mode 100644 index 000000000..f2e52b473 --- /dev/null +++ b/docs/_sass/color_schemes/meshtastic-dark.scss @@ -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 diff --git a/docs/_sass/color_schemes/meshtastic.scss b/docs/_sass/color_schemes/meshtastic.scss new file mode 100644 index 000000000..63c27599e --- /dev/null +++ b/docs/_sass/color_schemes/meshtastic.scss @@ -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 diff --git a/docs/assets/css/just-the-docs-meshtastic-dark.scss b/docs/assets/css/just-the-docs-meshtastic-dark.scss new file mode 100644 index 000000000..da3ad08f0 --- /dev/null +++ b/docs/assets/css/just-the-docs-meshtastic-dark.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="meshtastic-dark" %} diff --git a/docs/assets/css/just-the-docs-meshtastic.scss b/docs/assets/css/just-the-docs-meshtastic.scss new file mode 100644 index 000000000..86cb9b2e7 --- /dev/null +++ b/docs/assets/css/just-the-docs-meshtastic.scss @@ -0,0 +1,3 @@ +--- +--- +{% include css/just-the-docs.scss.liquid color_scheme="meshtastic" %} diff --git a/docs/assets/screenshots/README.md b/docs/assets/screenshots/README.md new file mode 100644 index 000000000..5fb2efe28 --- /dev/null +++ b/docs/assets/screenshots/README.md @@ -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 + diff --git a/docs/assets/screenshots/connections_bluetooth_scan.png b/docs/assets/screenshots/connections_bluetooth_scan.png new file mode 100644 index 000000000..637759558 Binary files /dev/null and b/docs/assets/screenshots/connections_bluetooth_scan.png differ diff --git a/docs/assets/screenshots/connections_connecting.png b/docs/assets/screenshots/connections_connecting.png new file mode 100644 index 000000000..1fc10a657 Binary files /dev/null and b/docs/assets/screenshots/connections_connecting.png differ diff --git a/docs/assets/screenshots/connections_disconnect.png b/docs/assets/screenshots/connections_disconnect.png new file mode 100644 index 000000000..1ba2c2438 Binary files /dev/null and b/docs/assets/screenshots/connections_disconnect.png differ diff --git a/docs/assets/screenshots/connections_empty_state.png b/docs/assets/screenshots/connections_empty_state.png new file mode 100644 index 000000000..322ac1162 Binary files /dev/null and b/docs/assets/screenshots/connections_empty_state.png differ diff --git a/docs/assets/screenshots/connections_transport_filters.png b/docs/assets/screenshots/connections_transport_filters.png new file mode 100644 index 000000000..239d970ac Binary files /dev/null and b/docs/assets/screenshots/connections_transport_filters.png differ diff --git a/docs/assets/screenshots/connections_wifi_device_found.png b/docs/assets/screenshots/connections_wifi_device_found.png new file mode 100644 index 000000000..6f499bd45 Binary files /dev/null and b/docs/assets/screenshots/connections_wifi_device_found.png differ diff --git a/docs/assets/screenshots/connections_wifi_scanning.png b/docs/assets/screenshots/connections_wifi_scanning.png new file mode 100644 index 000000000..9e45f1cdb Binary files /dev/null and b/docs/assets/screenshots/connections_wifi_scanning.png differ diff --git a/docs/assets/screenshots/connections_wifi_success.png b/docs/assets/screenshots/connections_wifi_success.png new file mode 100644 index 000000000..fe4b68550 Binary files /dev/null and b/docs/assets/screenshots/connections_wifi_success.png differ diff --git a/docs/assets/screenshots/docs-browser_chirpy.png b/docs/assets/screenshots/docs-browser_chirpy.png new file mode 100644 index 000000000..91c2f77b4 Binary files /dev/null and b/docs/assets/screenshots/docs-browser_chirpy.png differ diff --git a/docs/assets/screenshots/docs-browser_page.png b/docs/assets/screenshots/docs-browser_page.png new file mode 100644 index 000000000..e9d99045d Binary files /dev/null and b/docs/assets/screenshots/docs-browser_page.png differ diff --git a/docs/assets/screenshots/docs-browser_search.png b/docs/assets/screenshots/docs-browser_search.png new file mode 100644 index 000000000..77b732335 Binary files /dev/null and b/docs/assets/screenshots/docs-browser_search.png differ diff --git a/docs/assets/screenshots/docs-browser_toc.png b/docs/assets/screenshots/docs-browser_toc.png new file mode 100644 index 000000000..4bda5f9a4 Binary files /dev/null and b/docs/assets/screenshots/docs-browser_toc.png differ diff --git a/docs/assets/screenshots/firmware_checking.png b/docs/assets/screenshots/firmware_checking.png new file mode 100644 index 000000000..6a60c726d Binary files /dev/null and b/docs/assets/screenshots/firmware_checking.png differ diff --git a/docs/assets/screenshots/firmware_disclaimer.png b/docs/assets/screenshots/firmware_disclaimer.png new file mode 100644 index 000000000..322df1f5d Binary files /dev/null and b/docs/assets/screenshots/firmware_disclaimer.png differ diff --git a/docs/assets/screenshots/firmware_error.png b/docs/assets/screenshots/firmware_error.png new file mode 100644 index 000000000..5ddb4d376 Binary files /dev/null and b/docs/assets/screenshots/firmware_error.png differ diff --git a/docs/assets/screenshots/firmware_success.png b/docs/assets/screenshots/firmware_success.png new file mode 100644 index 000000000..d6ac649ff Binary files /dev/null and b/docs/assets/screenshots/firmware_success.png differ diff --git a/docs/assets/screenshots/map_controls_overlay.png b/docs/assets/screenshots/map_controls_overlay.png new file mode 100644 index 000000000..4a7ac640e Binary files /dev/null and b/docs/assets/screenshots/map_controls_overlay.png differ diff --git a/docs/assets/screenshots/messages-and-channels_channel_list.png b/docs/assets/screenshots/messages-and-channels_channel_list.png new file mode 100644 index 000000000..4054990a1 Binary files /dev/null and b/docs/assets/screenshots/messages-and-channels_channel_list.png differ diff --git a/docs/assets/screenshots/messages_channel_info.png b/docs/assets/screenshots/messages_channel_info.png new file mode 100644 index 000000000..6f022378f Binary files /dev/null and b/docs/assets/screenshots/messages_channel_info.png differ diff --git a/docs/assets/screenshots/messages_quick_chat.png b/docs/assets/screenshots/messages_quick_chat.png new file mode 100644 index 000000000..9418710db Binary files /dev/null and b/docs/assets/screenshots/messages_quick_chat.png differ diff --git a/docs/assets/screenshots/messages_reaction.png b/docs/assets/screenshots/messages_reaction.png new file mode 100644 index 000000000..8e795c97a Binary files /dev/null and b/docs/assets/screenshots/messages_reaction.png differ diff --git a/docs/assets/screenshots/node-metrics_telemetric_actions.png b/docs/assets/screenshots/node-metrics_telemetric_actions.png new file mode 100644 index 000000000..9bfd4f8d8 Binary files /dev/null and b/docs/assets/screenshots/node-metrics_telemetric_actions.png differ diff --git a/docs/assets/screenshots/nodes_battery_info.png b/docs/assets/screenshots/nodes_battery_info.png new file mode 100644 index 000000000..b936f8b18 Binary files /dev/null and b/docs/assets/screenshots/nodes_battery_info.png differ diff --git a/docs/assets/screenshots/nodes_detail_local.png b/docs/assets/screenshots/nodes_detail_local.png new file mode 100644 index 000000000..c556cd9f9 Binary files /dev/null and b/docs/assets/screenshots/nodes_detail_local.png differ diff --git a/docs/assets/screenshots/nodes_detail_section.png b/docs/assets/screenshots/nodes_detail_section.png new file mode 100644 index 000000000..1b2748a52 Binary files /dev/null and b/docs/assets/screenshots/nodes_detail_section.png differ diff --git a/docs/assets/screenshots/nodes_distance_info.png b/docs/assets/screenshots/nodes_distance_info.png new file mode 100644 index 000000000..a9fac3e1d Binary files /dev/null and b/docs/assets/screenshots/nodes_distance_info.png differ diff --git a/docs/assets/screenshots/nodes_environment_metrics.png b/docs/assets/screenshots/nodes_environment_metrics.png new file mode 100644 index 000000000..bd0192643 Binary files /dev/null and b/docs/assets/screenshots/nodes_environment_metrics.png differ diff --git a/docs/assets/screenshots/nodes_hops_info.png b/docs/assets/screenshots/nodes_hops_info.png new file mode 100644 index 000000000..82cdee039 Binary files /dev/null and b/docs/assets/screenshots/nodes_hops_info.png differ diff --git a/docs/assets/screenshots/nodes_last_heard.png b/docs/assets/screenshots/nodes_last_heard.png new file mode 100644 index 000000000..824627b2a Binary files /dev/null and b/docs/assets/screenshots/nodes_last_heard.png differ diff --git a/docs/assets/screenshots/nodes_node_list.png b/docs/assets/screenshots/nodes_node_list.png new file mode 100644 index 000000000..cc571c3b1 Binary files /dev/null and b/docs/assets/screenshots/nodes_node_list.png differ diff --git a/docs/assets/screenshots/nodes_position.png b/docs/assets/screenshots/nodes_position.png new file mode 100644 index 000000000..5ba415233 Binary files /dev/null and b/docs/assets/screenshots/nodes_position.png differ diff --git a/docs/assets/screenshots/nodes_signal_info.png b/docs/assets/screenshots/nodes_signal_info.png new file mode 100644 index 000000000..3a9addf1a Binary files /dev/null and b/docs/assets/screenshots/nodes_signal_info.png differ diff --git a/docs/assets/screenshots/onboarding_welcome.png b/docs/assets/screenshots/onboarding_welcome.png new file mode 100644 index 000000000..effb2699c Binary files /dev/null and b/docs/assets/screenshots/onboarding_welcome.png differ diff --git a/docs/assets/screenshots/settings-radio-user_lora_config.png b/docs/assets/screenshots/settings-radio-user_lora_config.png new file mode 100644 index 000000000..aecd2b620 Binary files /dev/null and b/docs/assets/screenshots/settings-radio-user_lora_config.png differ diff --git a/docs/assets/screenshots/settings_app_info.png b/docs/assets/screenshots/settings_app_info.png new file mode 100644 index 000000000..ff78aa035 Binary files /dev/null and b/docs/assets/screenshots/settings_app_info.png differ diff --git a/docs/assets/screenshots/settings_appearance.png b/docs/assets/screenshots/settings_appearance.png new file mode 100644 index 000000000..aecd2b620 Binary files /dev/null and b/docs/assets/screenshots/settings_appearance.png differ diff --git a/docs/assets/screenshots/settings_dropdown.png b/docs/assets/screenshots/settings_dropdown.png new file mode 100644 index 000000000..53419996a Binary files /dev/null and b/docs/assets/screenshots/settings_dropdown.png differ diff --git a/docs/assets/screenshots/settings_ipv4_field.png b/docs/assets/screenshots/settings_ipv4_field.png new file mode 100644 index 000000000..f99a06987 Binary files /dev/null and b/docs/assets/screenshots/settings_ipv4_field.png differ diff --git a/docs/assets/screenshots/settings_password_field.png b/docs/assets/screenshots/settings_password_field.png new file mode 100644 index 000000000..90400f3c6 Binary files /dev/null and b/docs/assets/screenshots/settings_password_field.png differ diff --git a/docs/assets/screenshots/settings_persistence.png b/docs/assets/screenshots/settings_persistence.png new file mode 100644 index 000000000..f2f5aac53 Binary files /dev/null and b/docs/assets/screenshots/settings_persistence.png differ diff --git a/docs/assets/screenshots/settings_slider.png b/docs/assets/screenshots/settings_slider.png new file mode 100644 index 000000000..f05643897 Binary files /dev/null and b/docs/assets/screenshots/settings_slider.png differ diff --git a/docs/assets/screenshots/settings_switch.png b/docs/assets/screenshots/settings_switch.png new file mode 100644 index 000000000..c1b455755 Binary files /dev/null and b/docs/assets/screenshots/settings_switch.png differ diff --git a/docs/assets/screenshots/settings_text_field.png b/docs/assets/screenshots/settings_text_field.png new file mode 100644 index 000000000..679e0da20 Binary files /dev/null and b/docs/assets/screenshots/settings_text_field.png differ diff --git a/docs/assets/screenshots/settings_titled_card.png b/docs/assets/screenshots/settings_titled_card.png new file mode 100644 index 000000000..a97e333da Binary files /dev/null and b/docs/assets/screenshots/settings_titled_card.png differ diff --git a/docs/developer.md b/docs/developer.md new file mode 100644 index 000000000..3802081cd --- /dev/null +++ b/docs/developer.md @@ -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 + + + + +**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. + + + diff --git a/docs/developer/adding-a-feature-module.md b/docs/developer/adding-a-feature-module.md new file mode 100644 index 000000000..e2bc3634d --- /dev/null +++ b/docs/developer/adding-a-feature-module.md @@ -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 { + 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 + +--- + diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md new file mode 100644 index 000000000..c3d5395ff --- /dev/null +++ b/docs/developer/architecture.md @@ -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. + +--- + diff --git a/docs/developer/codebase.md b/docs/developer/codebase.md new file mode 100644 index 000000000..045223dfa --- /dev/null +++ b/docs/developer/codebase.md @@ -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 | + +--- + diff --git a/docs/developer/contributing.md b/docs/developer/contributing.md new file mode 100644 index 000000000..7783fac1a --- /dev/null +++ b/docs/developer/contributing.md @@ -0,0 +1,97 @@ +--- +title: Contributing +nav_order: 8 +last_updated: 2026-05-13 +aliases: + - contributing + - pull-request + - branch-naming +--- + +# Contributing + +Guidelines for contributing to the Meshtastic Android/Desktop/iOS project. + +## Branch Naming + +Feature branches follow the pattern: +``` +{issue-number}-{short-description} +``` + +Examples: +- `003-app-docs-markdown` +- `001-local-mesh-discovery` +- `fix/bluetooth-reconnect` + +## Development Workflow + +1. **Fork** the repository (external contributors) or create a branch (maintainers). +2. **Implement** your changes following the architecture guidelines. +3. **Test** locally: `./gradlew spotlessCheck detekt kmpSmokeCompile test allTests` +4. **Commit** with clear, descriptive messages. +5. **Push** and open a Pull Request. + +## Commit Messages + +Follow conventional commit style: +``` +feat(docs): add in-app documentation browser +fix(ble): handle reconnection timeout +refactor(navigation): migrate to typed routes +test(search): add keyword ranking tests +``` + +## Pull Request Checklist + +Before submitting: +- [ ] Code compiles on all targets: `./gradlew kmpSmokeCompile` +- [ ] All tests pass: `./gradlew allTests` +- [ ] Code style passes: `./gradlew spotlessCheck` +- [ ] Static analysis passes: `./gradlew detekt` +- [ ] New code has appropriate test coverage +- [ ] No `android.*` imports in `commonMain` +- [ ] Koin modules registered if new DI is added +- [ ] Routes added to `Routes.kt` if new navigation is introduced +- [ ] Documentation updated if user-facing behavior changes + +## Code Style + +- **Formatting:** Enforced by Spotless (KtLint rules) +- **Static analysis:** Detekt with project-specific configuration +- **Imports:** No wildcard imports; organized automatically by Spotless +- **Line length:** 120 characters maximum + +Run formatting: +```bash +./gradlew spotlessApply +``` + +## Architecture Rules + +- Feature modules must not depend on other feature modules +- `commonMain` must not contain `android.*`, `java.io.*`, or platform-specific imports +- Prefer interface + DI over `expect`/`actual` for complex platform behaviors +- All navigation routes must be `@Serializable` and defined in `Routes.kt` +- Use Koin annotations (`@Single`, `@Factory`, `@Module`) for dependency injection + +## Verification + +Full pre-merge verification: +```bash +./gradlew spotlessCheck detekt kmpSmokeCompile test allTests +``` + +For docs-specific changes, also run: +```bash +./gradlew generateDocsBundle validateDocsBundle +``` + +## Getting Help + +- [Meshtastic Discord](https://discord.gg/meshtastic) — `#app-development` channel +- GitHub Issues — for bug reports and feature requests +- GitHub Discussions — for questions and ideas + +--- + diff --git a/docs/developer/measurement.md b/docs/developer/measurement.md new file mode 100644 index 000000000..83eceac3a --- /dev/null +++ b/docs/developer/measurement.md @@ -0,0 +1,166 @@ +--- +title: Measurement & Formatting +nav_order: 9 +last_updated: 2026-05-13 +aliases: + - measurement + - metric-formatter + - number-formatter +--- + +# Measurement & Formatting + +How the Meshtastic Android/KMP app formats numbers, units, and locale-sensitive values. + +--- + +## Overview + +All measurement data transmitted by Meshtastic radios uses **metric units** (meters, °C, hPa, m/s, etc.). The app converts and formats these values for display using two core utilities: + +| Utility | Location | Purpose | +|---|---|---| +| `MetricFormatter` | `core/common/.../util/MetricFormatter.kt` | Converts and formats physical measurements (temperature, pressure, speed, etc.) | +| `NumberFormatter` | `core/common/.../util/NumberFormatter.kt` | Low-level fixed-point number formatting with locale-independent dot separator | + +Both live in `org.meshtastic.core.common.util` and are available to all KMP targets (Android, Desktop, iOS). + +--- + +## MetricFormatter API + +`MetricFormatter` is a Kotlin `object` with pure functions for each measurement type: + +```kotlin +object MetricFormatter { + fun temperature(celsius: Float, isFahrenheit: Boolean): String + fun voltage(volts: Float, decimalPlaces: Int = 2): String + fun current(milliAmps: Float, decimalPlaces: Int = 1): String + fun percent(value: Float, decimalPlaces: Int = 1): String + fun humidity(value: Float): String + fun pressure(hPa: Float, decimalPlaces: Int = 1): String + fun snr(value: Float, decimalPlaces: Int = 1): String + fun rssi(value: Int): String + fun windSpeed(metersPerSecond: Float, decimalPlaces: Int = 1): String + fun rainfall(millimeters: Float, decimalPlaces: Int = 1): String +} +``` + +### Usage + +```kotlin +// Temperature — Fahrenheit conversion is handled automatically +MetricFormatter.temperature(22.5f, isFahrenheit = true) // "72.5°F" +MetricFormatter.temperature(22.5f, isFahrenheit = false) // "22.5°C" + +// Signal metrics +MetricFormatter.snr(-5.2f) // "-5.2 dB" +MetricFormatter.rssi(-97) // "-97 dBm" + +// Environment +MetricFormatter.pressure(1013.25f) // "1013.3 hPa" +MetricFormatter.humidity(65.0f) // "65%" +MetricFormatter.windSpeed(3.7f) // "3.7 m/s" +MetricFormatter.rainfall(12.3f) // "12.3 mm" + +// Power +MetricFormatter.voltage(3.95f) // "3.95 V" +MetricFormatter.current(125.0f) // "125.0 mA" +``` + +--- + +## NumberFormatter + +`NumberFormatter` provides locale-independent decimal formatting using pure arithmetic (no `String.format` or `DecimalFormat`): + +```kotlin +object NumberFormatter { + fun format(value: Double, decimalPlaces: Int): String + fun format(value: Float, decimalPlaces: Int): String +} +``` + +> **Why locale-independent?** Meshtastic is a mesh networking app where consistency matters — sensor readings shared between nodes should look the same everywhere. `NumberFormatter` always uses `.` as the decimal separator. + +--- + +## Temperature Conversion + +Temperature is the only measurement that performs a unit conversion. The `isFahrenheit` flag is typically sourced from the user's device locale or preferences: + +``` +°F = °C × 1.8 + 32 +``` + +All other measurements display in their native metric units. The user-facing `units-and-locale.md` page explains what end users see. + +--- + +## Adding a New Measurement Type + +To add a new measurement formatter: + +1. **Add a function to `MetricFormatter`** in `core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt`: + + ```kotlin + fun radiation(microSieverts: Float, decimalPlaces: Int = 2): String = + "${NumberFormatter.format(microSieverts, decimalPlaces)} μSv/h" + ``` + +2. **Add tests** in `core/common/src/commonTest/`: + + ```kotlin + @Test + fun radiationFormatting() { + assertEquals("0.15 μSv/h", MetricFormatter.radiation(0.15f)) + assertEquals("1.23 μSv/h", MetricFormatter.radiation(1.234f)) + } + ``` + +3. **Use in UI** — call from any `commonMain` composable or ViewModel: + + ```kotlin + Text(text = MetricFormatter.radiation(node.radiationLevel)) + ``` + +4. **Run verification**: + ```bash + ./gradlew :core:common:allTests + ``` + +--- + +## DateFormatter + +Date and time formatting uses the `DateFormatter` interface with platform-specific implementations: + +| Function | Output Example | +|---|---| +| `formatRelativeTime()` | "5 min ago" | +| `formatDateTime()` | "May 13, 2026 2:30 PM" | +| `formatShortDate()` | "May 13" | +| `formatTime()` | "2:30 PM" | +| `formatTimeWithSeconds()` | "2:30:45 PM" | +| `formatDate()` | "2026-05-13" | + +Unlike `MetricFormatter`, `DateFormatter` is an **interface** with platform `expect`/`actual` implementations because date formatting inherently depends on platform locale APIs. + +--- + +## Design Decisions + +| Decision | Rationale | +|---|---| +| Locale-independent decimal separator (`.`) | Mesh data shared between nodes must be consistent | +| Pure arithmetic formatting (no `DecimalFormat`) | Works identically on JVM, Native, and JS targets | +| Temperature is the only converted unit | All other metric units are universally understood in their native form | +| `object` singleton pattern | Stateless utility — no instance management needed | + +--- + +## Related + +- **User-facing docs**: `docs/user/units-and-locale.md` explains what end users see +- **Source code**: `core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt` +- **Tests**: `core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt` diff --git a/docs/developer/navigation-and-deep-links.md b/docs/developer/navigation-and-deep-links.md new file mode 100644 index 000000000..08622c60a --- /dev/null +++ b/docs/developer/navigation-and-deep-links.md @@ -0,0 +1,121 @@ +--- +title: Navigation & Deep Links +nav_order: 4 +last_updated: 2026-05-13 +aliases: + - deeplinks + - navigation-3 + - routes +--- + +# Navigation & Deep Links + +The app uses **Navigation 3** with typed, serializable routes and centralized deep link resolution. + +## Route Architecture + +All routes are defined in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`. + +### Route Hierarchy + +```kotlin +interface Route : NavKey // All routes implement NavKey +interface Graph : Route // Graph roots for navigation hierarchies + +@Serializable +sealed interface SettingsRoute : Route { + @Serializable data class SettingsGraph(val destNum: Int?) : SettingsRoute, Graph + @Serializable data object DeviceConfiguration : SettingsRoute + @Serializable data object HelpDocs : SettingsRoute + @Serializable data class HelpDocPage(val pageId: String) : SettingsRoute + // ... +} +``` + +### Conventions + +- Routes are `@Serializable` for state restoration +- Use `data object` for routes without parameters +- Use `data class` for parameterized routes +- Group related routes under a `sealed interface` +- Graph entry points implement both the route interface and `Graph` + +## Deep Link Router + +`DeepLinkRouter` in `core/navigation` maps URI deep links to typed backstack lists. + +### URI Format + +``` +meshtastic://meshtastic/{path} +``` + +### Supported Deep Links + +| URI Path | Route | Notes | +|----------|-------|-------| +| `/settings` | `SettingsRoute.SettingsGraph(null)` | Settings root | +| `/settings/helpDocs` | `SettingsRoute.HelpDocs` | Docs browser | +| `/settings/helpDocs/{pageId}` | `SettingsRoute.HelpDocPage(pageId)` | Specific doc page | +| `/settings/help-docs` | `SettingsRoute.HelpDocs` | Compatibility alias | +| `/nodes` | `NodesRoute.Nodes` | Node list | +| `/nodes/{destNum}` | `NodesRoute.NodeDetail(destNum)` | Node detail | +| `/messages/{contactKey}` | `ContactsRoute.Messages(contactKey)` | Conversation | +| `/map` | `MapRoute.Map(null)` | Map view | + +### Backstack Synthesis + +Deep links synthesize a full backstack, not just the target screen: + +```kotlin +// /settings/helpDocs/messages-and-channels produces: +listOf( + SettingsRoute.SettingsGraph(null), + SettingsRoute.HelpDocs, + SettingsRoute.HelpDocPage("messages-and-channels"), +) +``` + +This ensures the user can navigate "up" correctly. + +## Adding a Deep Link + +1. Define the typed route in `Routes.kt`. +2. Add the mapping in `DeepLinkRouter.settingsSubRoutes` (or equivalent for other graphs). +3. Add a test in `DeepLinkRouterTest.kt`. +4. Register the navigation entry in the appropriate feature module. + +## Navigation Entry Registration + +Each feature module provides entries via an extension function: + +```kotlin +fun EntryProviderScope<*>.docsEntries(backStack: NavBackStack) { + entry { DocsBrowserScreen(backStack) } + entry { DocsPageRouteScreen(it.pageId, backStack) } +} +``` + +These are called from the settings navigation composition. + +## Testing + +Deep link routing is tested in: +``` +core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt +``` + +Example: +```kotlin +@Test +fun `help docs deep link routes correctly`() { + val result = DeepLinkRouter.route(CommonUri.parse("meshtastic://meshtastic/settings/helpDocs")) + assertEquals( + listOf(SettingsRoute.SettingsGraph(null), SettingsRoute.HelpDocs), + result, + ) +} +``` + +--- + diff --git a/docs/developer/persistence.md b/docs/developer/persistence.md new file mode 100644 index 000000000..9b42538e9 --- /dev/null +++ b/docs/developer/persistence.md @@ -0,0 +1,84 @@ +--- +title: Persistence +nav_order: 6 +last_updated: 2026-05-13 +aliases: + - room + - database + - datastore + - prefs +--- + +# Persistence + +How the Meshtastic app stores data across different mechanisms. + +## Room KMP Database + +**Module:** `core:database` + +The primary structured data store: +- Node information and history +- Message history +- Waypoints +- Telemetry data +- Channel configurations + +### Key Points + +- Uses Room KMP for cross-platform compatibility +- Migrations managed through Room's built-in migration system +- DAO interfaces live in `core:database` +- Repository layer in `core:repository` provides the public API + +### What's Stored in Room + +| Entity | Description | +|--------|-------------| +| Nodes | All known mesh nodes and metadata | +| Messages | Message history (channel and direct) | +| Waypoints | Shared geographic points | +| Telemetry | Device, environment, power metrics | +| Channels | Channel configurations | + +## DataStore Preferences + +**Module:** `core:datastore` + +For lightweight key-value preferences: +- Connection state +- Last connected device +- UI preferences + +## Core Prefs + +**Module:** `core:prefs` + +Higher-level preferences abstraction: +- User-facing settings +- App behavior configuration +- Feature toggles + +## What Docs Intentionally Skip + +The `feature:docs` module does **not** use Room or any persistent database: +- Documentation content is packaged as build-time assets +- The docs corpus is versioned with the app binary +- No migration story is needed for docs content +- Optional UX state (last viewed page) could use `core:prefs` but is not part of the docs data model + +This is an intentional design decision to keep documentation: +- Fully offline without database overhead +- Replaceable with each app update +- Simple to validate and test + +## Best Practices + +- Use Room for structured, queryable data that changes at runtime +- Use DataStore for simple preferences and state +- Use bundled resources/assets for static content +- Never store sensitive data (keys, passwords) in plain Room tables +- Always provide migrations for schema changes + +--- + diff --git a/docs/developer/testing.md b/docs/developer/testing.md new file mode 100644 index 000000000..816ab6527 --- /dev/null +++ b/docs/developer/testing.md @@ -0,0 +1,121 @@ +--- +title: Testing +nav_order: 7 +last_updated: 2026-05-13 +aliases: + - tests + - unit-tests + - screenshot-tests +--- + +# Testing + +Testing strategy and practices for the Meshtastic KMP project. + +## Test Categories + +### KMP Unit Tests (`commonTest`) + +Shared tests that run on all platforms: + +```bash +./gradlew allTests +``` + +- Business logic tests +- Data model validation +- Search/ranking algorithm tests +- Route serialization tests + +### Android Host Tests + +Android-specific tests that run on JVM: + +```bash +./gradlew test +``` + +- ViewModel tests +- Repository tests with Room fakes +- Android-specific integration tests + +### Compose UI Tests + +Compose Multiplatform UI test framework: + +```kotlin +@Test +fun myScreenTest() = runComposeUiTest { + setContent { MyScreen() } + onNodeWithText("Expected").assertIsDisplayed() +} +``` + +Located in `commonTest` or `jvmTest` source sets. + +### Screenshot Tests + +Preferred: **Roborazzi** (Gradle-native, Ubuntu CI compatible) +Fallback: **Paparazzi** (Android-view-centric) + +```bash +./gradlew recordDocsScreenshots # Record golden images +./gradlew verifyDocsScreenshots # Compare against goldens +``` + +## Test Organization + +``` +feature/my-feature/src/ +├── commonTest/kotlin/org/meshtastic/feature/myfeature/ +│ ├── MyBusinessLogicTest.kt +│ └── MyModelTest.kt +└── jvmTest/kotlin/org/meshtastic/feature/myfeature/ + └── MyDesktopSpecificTest.kt +``` + +## Testing Guidelines + +### DO + +- Write tests in `commonTest` when possible (runs everywhere) +- Test business logic independently from UI +- Use fakes/stubs instead of mocks where practical +- Test edge cases: empty states, error states, boundary values +- Test deep link routing in `DeepLinkRouterTest` +- Keep tests fast — no network, no disk I/O in unit tests + +### DON'T + +- Don't test framework behavior (Compose internals, Room queries) +- Don't create tests that depend on other feature modules +- Don't use `Thread.sleep` — use coroutine test dispatchers +- Don't rely on test execution order + +## Running Tests + +```bash +# All KMP tests +./gradlew allTests + +# Specific module +./gradlew :feature:docs:allTests + +# Code quality +./gradlew spotlessCheck detekt + +# Full verification +./gradlew spotlessCheck detekt kmpSmokeCompile test allTests +``` + +## CI Integration + +Tests run automatically on: +- Pull request creation/update +- Push to `main` +- Pre-release validation + +The CI workflow uses `ubuntu-24.04` with JDK 21 and Gradle caching. + +--- + diff --git a/docs/developer/transport.md b/docs/developer/transport.md new file mode 100644 index 000000000..15467f0ba --- /dev/null +++ b/docs/developer/transport.md @@ -0,0 +1,98 @@ +--- +title: Transport +nav_order: 5 +last_updated: 2026-05-13 +aliases: + - ble + - serial + - tcp + - radio-transport +--- + +# Transport + +Meshtastic communicates between the app and radio hardware through multiple transport mechanisms. + +## Transport Abstraction + +The transport layer is abstracted through interfaces in `core/network` and `core/ble`, allowing the app to work identically regardless of the underlying connection type. + +``` +App ← RadioController → Transport (BLE | Serial | TCP) +``` + +## Bluetooth Low Energy (BLE) + +**Module:** `core:ble` +**Platforms:** Android, (planned: iOS) + +The primary transport for Android mobile devices: +- Service discovery for Meshtastic GATT services +- Characteristic-based read/write for protobuf packets +- Connection state management and automatic reconnection +- MTU negotiation for optimal packet sizes + +### Key Classes + +- `core/ble/` — BLE scanning, connection, and GATT operations +- Platform-specific implementations in `androidMain` + +## USB Serial + +**Module:** `core:network` +**Platforms:** Android (OTG), Desktop + +Serial communication over USB: +- Uses `usb-serial-for-android` library on Android +- Direct serial port access on Desktop (JVM) +- Probe table for supported USB vendor/product IDs +- Automatic detection when USB device is connected + +### Key Classes + +- Serial prober and transport factory in `core/network` +- Desktop-specific serial in `desktop/src/main/kotlin/.../radio/` + +## TCP/IP + +**Module:** `core:network` +**Platforms:** Android, Desktop, iOS + +Network-based transport for WiFi-enabled radios: +- TCP socket connection to radio's IP address +- Default port: 4403 +- Used for development with simulated radios +- Available when BLE/USB is impractical + +## Transport Factory + +The `RadioTransportFactory` interface abstracts transport creation: + +```kotlin +interface RadioTransportFactory { + fun createTransport(config: TransportConfig): RadioTransport +} +``` + +Platform-specific implementations: +- **Android:** Supports BLE + USB + TCP +- **Desktop:** Supports USB + TCP (no BLE) +- **iOS:** Planned BLE + TCP + +## Connection Lifecycle + +1. **Discovery** — Scan for available radios (BLE scan / USB detect / manual TCP) +2. **Connection** — Establish link to selected radio +3. **Handshake** — Exchange node info and configuration +4. **Active** — Normal message exchange +5. **Disconnection** — Clean teardown or error recovery + +## Adding a New Transport + +1. Implement `RadioTransport` interface +2. Register in platform-specific `RadioTransportFactory` +3. Add connection UI in `feature:connections` +4. Update DI bindings for the platform + +--- + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..35d3f7064 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,30 @@ +--- +title: Home +layout: default +nav_order: 0 +--- + +# Meshtastic Android App Documentation + +User and developer documentation for the Meshtastic Android, Desktop, and iOS applications powered by KMP (Kotlin Multiplatform). + +Use the sidebar navigation to browse the **User Guide** for app features and the **Developer Guide** for contributing to the project. + +--- + +## Quick Links + +| Guide | Description | +|-------|-------------| +| [Getting Started](user/onboarding) | Connect your first radio and send a message | +| [Messages & Channels](user/messages-and-channels) | Channel broadcasts, direct messages, reactions, and encryption | +| [Nodes](user/nodes) | Understanding the mesh network node list | +| [Signal Meter](user/signal-meter) | How the LoRa signal quality meter works | +| [Units & Locale](user/units-and-locale) | How temperatures, distances, and times adapt to your region | +| [Desktop App](user/desktop) | Linux, macOS, and Windows desktop usage | +| [Architecture](developer/architecture) | App architecture overview for contributors | +| [Contributing](developer/contributing) | Branch naming, PR workflow, and verification commands | + +--- + +> This documentation is served from the same markdown source that powers the in-app **Help & Documentation** browser. diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 000000000..347d70d00 --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,56 @@ +--- +title: Translations +layout: default +nav_order: 99 +--- + +# Translations + +This documentation is translated by the community via [Crowdin](https://crowdin.com/project/meshtastic-android). Translations appear here automatically as volunteers contribute them. + +## Available Languages + +{% for locale in site.data.locales %} +{% assign locale_code = locale[0] %} +{% assign locale_info = locale[1] %} +{% assign locale_index = locale_code | append: "/index.md" %} +{% assign has_content = false %} +{% for p in site.pages %} + {% if p.path contains locale_code %} + {% assign has_content = true %} + {% break %} + {% endif %} +{% endfor %} + +{% if has_content %} +- [{{ locale_info.name }}]({{ locale_code }}/) ({{ locale_code }}) +{% endif %} +{% endfor %} + +{% comment %} Show notice if no translations exist yet {% endcomment %} +{% assign any_locale_exists = false %} +{% for locale in site.data.locales %} + {% assign locale_code = locale[0] %} + {% for p in site.pages %} + {% if p.path contains locale_code %} + {% assign any_locale_exists = true %} + {% break %} + {% endif %} + {% endfor %} + {% if any_locale_exists %}{% break %}{% endif %} +{% endfor %} + +{% unless any_locale_exists %} +> No translations available yet. Want to help? [Join our Crowdin project →](https://crowdin.com/project/meshtastic-android) +{% endunless %} + +--- + +## Contributing Translations + +1. Visit [Crowdin](https://crowdin.com/project/meshtastic-android) +2. Select a language +3. Translate the User Guide documentation files +4. Translations are automatically synced to this site via PR + +Translation coverage and quality are tracked per-language. Pages without full translation show the English original for untranslated sections. diff --git a/docs/user.md b/docs/user.md new file mode 100644 index 000000000..72fcfbf5b --- /dev/null +++ b/docs/user.md @@ -0,0 +1,38 @@ +--- +title: User Guide +layout: default +nav_order: 1 +has_children: true +parent: "" +--- + +# User Guide + +Documentation for using the Meshtastic Android and Desktop app. + +--- + +## What's New in the Docs + + + + +**May 2026** — [Translate the App](user/translate) — New page explaining how to contribute translations to the Meshtastic app via Crowdin. + +**May 2026** — [Units & Locale](user/units-and-locale) — New page explaining how the app automatically adapts temperatures, distances, speeds, and times to your device's regional settings. + +**May 2026** — [Signal Meter](user/signal-meter) — New page explaining how the LoRa signal quality meter works, why negative SNR values are normal, and how to interpret RSSI vs. SNR. + +**May 2026** — [Messages & Channels](user/messages-and-channels) — Added reactions, message actions, and delivery retry documentation. + +**May 2026** — [Nodes](user/nodes) — Corrected filtering and sorting documentation to match actual app capabilities (7 sort options, 6 filter toggles). + +**May 2026** — [Desktop App](user/desktop) — Added keyboard shortcuts table and confirmed system tray support. + +**May 2026** — [Getting Started](user/onboarding) — Added Critical Alerts permission screen and expanded permission explanations. + + + diff --git a/docs/user/connections.md b/docs/user/connections.md new file mode 100644 index 000000000..574433b50 --- /dev/null +++ b/docs/user/connections.md @@ -0,0 +1,113 @@ +--- +title: Connections +nav_order: 2 +last_updated: 2026-05-13 +description: Connect your phone or desktop to a Meshtastic radio via Bluetooth, USB, or TCP/IP. +aliases: + - bluetooth + - usb + - tcp + - pairing +--- + +# Connections + +Meshtastic supports multiple transport methods to communicate between your phone/desktop and a radio node. + +## Bluetooth (BLE) + +Bluetooth Low Energy is the default and most common connection method on Android. + +### Pairing a Device + +1. Ensure your Meshtastic radio is powered on and in pairing mode. +2. Open the app and navigate to **Connections**. +3. Tap **Scan for Devices** — nearby Meshtastic radios will appear. +4. Select your device from the list. +5. Accept the Bluetooth pairing prompt if shown. + +![Device list item](/assets/screenshots/connections_bluetooth_scan.png) + +You can filter devices by transport type using the filter chips at the top: + +![Transport filter chips](/assets/screenshots/connections_transport_filters.png) + +> 💡 **Tip:** If your device doesn't appear, check that Bluetooth and Location permissions are granted, and that the radio is not already connected to another device. + +### Connection Status + +| Icon | State | Description | +|------|-------|-------------| +| 🟢 | Connected | Active radio link established | +| 🟡 | Connecting | Handshake in progress | +| 🔴 | Disconnected | No active connection | +| ⚪ | Not configured | No device selected | + +When connecting, a status indicator shows the current connection state: + +![Connecting status](/assets/screenshots/connections_connecting.png) + +If no devices are found, the app shows an empty state with instructions: + +![No devices found](/assets/screenshots/connections_empty_state.png) + +### Troubleshooting Bluetooth + +- **Device not found:** Toggle Bluetooth off/on, ensure location is enabled. +- **Connection drops:** Move closer to the radio; check for interference. +- **Pairing rejected:** Forget the device in Android Bluetooth settings and retry. + +## USB Serial + +USB connections provide a wired alternative, useful for desktop or when Bluetooth is unavailable. + +### Setup + +1. Connect your radio via USB cable to your device. +2. The app will prompt for USB permission — tap **Allow**. +3. The connection is established automatically. + +> ⚠️ **Note:** USB connections require OTG support on Android devices. + +## TCP/IP (WiFi) + +Some Meshtastic radios support WiFi connectivity, allowing TCP-based connections. + +### Configuration + +1. Connect your radio to a WiFi network via the radio's web interface or settings. +2. In the app, go to **Connections → TCP**. +3. Enter the radio's IP address and port (default: 4403). +4. Tap **Connect**. + +### When to Use TCP + +- Radio is on the same local network +- Testing with a simulated radio +- Environments where Bluetooth has interference issues + +## Connection Priority + +The app attempts connections in this order: +1. Last successful Bluetooth device +2. USB (if detected) +3. Manual TCP (if configured) + +## Desktop Connections + +On Desktop (Linux/macOS/Windows), the app supports: +- **USB Serial** — primary connection method +- **TCP/IP** — for network-connected radios +- Bluetooth is **not** currently supported on Desktop + +See [Desktop App](desktop) for platform-specific details and keyboard shortcuts. + +## Related Topics + +- [Getting Started](onboarding) — first-launch setup and permissions +- [Settings — Radio & User](settings-radio-user) — Bluetooth and network configuration +- [Desktop App](desktop) — desktop-specific connection details +- [Supported devices](https://meshtastic.org/docs/hardware/devices) — full list of compatible radios on meshtastic.org + +--- + diff --git a/docs/user/desktop.md b/docs/user/desktop.md new file mode 100644 index 000000000..2734b9ab3 --- /dev/null +++ b/docs/user/desktop.md @@ -0,0 +1,128 @@ +--- +title: Desktop App +nav_order: 14 +last_updated: 2026-05-13 +description: Install and use the Meshtastic Desktop app on Linux, macOS, and Windows — connections, feature parity, and keyboard shortcuts. +aliases: + - desktop + - linux + - macos + - windows + - jvm +--- + +# Desktop App + +The Meshtastic Desktop application provides the same mesh communication features on Linux, macOS, and Windows. + +## Overview + +The Desktop app shares its core codebase with the Android app through Kotlin Multiplatform (KMP). Most features work identically across platforms. + +## Installation + +### Linux + +- Download the `.deb` or `.AppImage` package from the releases page +- Or build from source using `./gradlew :desktop:run` + +### macOS + +- Download the `.dmg` package from releases +- Or build from source + +### Windows + +- Download the `.msi` installer from releases +- Or build from source + +## Connecting Your Radio + +### USB Serial (Primary) + +The most reliable connection method on Desktop: + +1. Connect your Meshtastic radio via USB cable. +2. The app should detect the serial port automatically. +3. If not detected, select the correct serial port from the connections menu. + +### TCP/IP + +For network-connected radios: + +1. Enter the radio's IP address and port (default: 4403). +2. Click **Connect**. + +### Bluetooth + +> ⚠️ **Note:** Bluetooth is not currently supported on the Desktop app. Use USB or TCP connections. + +## Feature Parity + +| Feature | Android | Desktop | Notes | +|---------|---------|---------|-------| +| Messaging | ✓ | ✓ | Full parity | +| Node List | ✓ | ✓ | Full parity | +| Map | ✓ | ✓ | Full parity | +| Settings | ✓ | ✓ | Full parity | +| Bluetooth | ✓ | ✗ | USB/TCP on desktop | +| Firmware Update OTA | ✓ | ✗ | Use web flasher | +| Notifications | ✓ | ✓ | Native OS notifications | +| Widgets | ✓ | ✗ | Android-only | +| AI Assistant (Chirpy) | ✓* | ✗ | Google flavor Android only | + +*Chirpy AI requires Android 14+ on Google flavor builds with supported hardware. + +## UI Differences + +The Desktop app uses the same Compose Multiplatform UI with adaptations for larger screens and desktop interaction. + +### Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| **⌘Q** / **Ctrl+Q** | Quit the application | +| **⌘,** / **Ctrl+,** | Open Settings | +| **⌘1** / **Ctrl+1** | Switch to Conversations tab | +| **⌘2** / **Ctrl+2** | Switch to Nodes tab | +| **⌘3** / **Ctrl+3** | Switch to Map tab | +| **⌘4** / **Ctrl+4** | Switch to Connections tab | + +### Window & System Tray + +- **Window resizing** — responsive layout adapts to window dimensions +- **System tray** — minimize to system tray for background mesh operation +- **Tray menu** — right-click the tray icon to show window or quit +- **Mouse interaction** — hover states and standard desktop navigation + +### Notification Preferences + +The Desktop app provides in-app toggles for controlling which notifications are shown — messages, new nodes, and low battery alerts. Access these from **Settings → Notifications** within the app. + +## Building from Source + +```bash +git clone https://github.com/meshtastic/Meshtastic-Android.git +cd Meshtastic-Android +git submodule update --init +./gradlew :desktop:run +``` + +Requirements: +- JDK 21 +- No Android SDK required for desktop-only builds + +## Known Limitations + +- No Bluetooth support +- No OTA firmware updates (use web flasher) +- Some Android-specific features (widgets, specific notification channels) are unavailable +- Performance may vary on low-spec hardware running Compose Desktop + +## Related Topics + +- [Connections](connections) — connection methods overview +- [Firmware Updates](firmware) — use the [Web Flasher](https://flasher.meshtastic.org) for desktop firmware updates + +--- + diff --git a/docs/user/discovery.md b/docs/user/discovery.md new file mode 100644 index 000000000..55c5d8d34 --- /dev/null +++ b/docs/user/discovery.md @@ -0,0 +1,114 @@ +--- +title: Discovery +nav_order: 12 +last_updated: 2026-05-13 +description: Explore your mesh network — traceroute paths, neighbor maps, and node discovery tools. +aliases: + - mesh-discovery + - local-discovery + - network-scan + - traceroute + - neighbor-info +--- + +# Discovery + +Discovery tools help you understand **how** your mesh network is connected — which nodes can hear each other, what paths messages take, and where bottlenecks or weak links exist. + +> 💡 **Tip:** You don't need a dedicated "discovery mode" to start exploring your mesh. The tools below are available right now from the node list and node detail screens. + +--- + +## Traceroute + +Traceroute reveals the exact path a message takes from your node to any other node on the mesh. It's the single most useful tool for debugging connectivity problems. + +### Running a Traceroute + +1. Navigate to **Nodes** and tap the node you want to trace. +2. On the node detail screen, tap **Traceroute**. +3. The app sends a traceroute request and waits for the response. +4. Results display each hop in order, with signal quality at every step. + +### Reading the Results + +A traceroute result looks like this: + +``` +You → Node A (SNR: 8.5, RSSI: -95) → Node B (SNR: 5.2, RSSI: -108) → Target +``` + +Each hop represents a relay node that forwarded the message. The SNR and RSSI values at each hop tell you about the link quality on that specific segment. + +| What to look for | What it means | +|------------------|---------------| +| All hops show Good SNR (> 5 dB) | Healthy path — messages flow reliably | +| One hop shows Bad SNR (< 0 dB) | Weak link — this relay segment is fragile | +| Many hops (4+) | Long path — consider repositioning a node to shorten it | +| Different path on retry | Mesh is adapting — multiple routes exist (this is good!) | + +> 💡 **Tip:** Run traceroute several times over a few minutes. If the path changes, your mesh has redundant routes — a sign of a well-connected network. + +### Troubleshooting with Traceroute + +- **"No route found"** — The target node may be offline, out of range, or on a different channel. Check that both nodes share at least one channel with the same encryption key. +- **Traceroute times out** — The path may be too long (exceeds hop limit) or a relay node is congested. Try increasing the hop limit in **Settings → LoRa Config**. +- **Asymmetric paths** — A traceroute from A→B may take a different path than B→A. This is normal — radio propagation is not always symmetric. + +--- + +## Neighbor Info + +The Neighbor Info module lets each node broadcast a list of the nodes it can **directly hear** (single-hop). When multiple nodes share their neighbor lists, you can piece together a topology map of the entire mesh. + +### Enabling Neighbor Info + +1. Navigate to **Settings → Module Config → Neighbor Info**. +2. Enable the module. +3. Set the broadcast interval (default: 900 seconds / 15 minutes). + +Once enabled, your node periodically broadcasts its neighbor table. Other nodes with Neighbor Info enabled do the same. + +### Viewing Neighbor Data + +- Open any node's detail screen and look for the **Neighbors** section. +- Each neighbor entry shows the node that was directly heard and its signal quality. +- Combine neighbor data from multiple nodes to understand the full mesh topology. + +> ⚠️ **Note:** Neighbor Info increases airtime usage because every enabled node periodically broadcasts its neighbor list. On busy meshes with many nodes, consider longer broadcast intervals (3600 seconds or more) to avoid congestion. + +--- + +## Node List as a Discovery Tool + +The node list itself is a powerful discovery tool when you use its filtering and sorting features effectively. + +### Finding New Nodes + +- Sort by **Last heard** to see the most recently active nodes at the top. +- Enable **Include unknown** to see nodes that have appeared on the mesh but haven't sent user info yet — these are often newly powered-on devices. + +### Assessing Connectivity + +- Sort by **Hops away** to see which nodes are directly reachable (0 hops) versus relayed. +- Sort by **Distance** to find nearby nodes and verify they're reachable. +- Use **Exclude MQTT** to focus on nodes reachable over radio (not via internet bridge). + +### Infrastructure Audit + +- Disable **Exclude infrastructure** to see Router, Repeater, and Router Client nodes. +- Check their signal quality and last-heard times to verify your infrastructure nodes are healthy. + +See [Nodes](nodes) for full details on filtering and sorting options. + +--- + +## Tips for Mesh Exploration + +- **Start with traceroute** — it gives you immediate, actionable information about a specific path. +- **Enable Neighbor Info on key nodes** — especially routers and repeaters, to build a picture of the backbone. +- **Check the map** — node positions on the [Map](map-and-waypoints) combined with signal data help you understand why some links are strong and others are weak. +- **Compare signal over time** — use the [Signal Meter](signal-meter) guide to interpret SNR and RSSI values correctly. + +--- + diff --git a/docs/user/firmware.md b/docs/user/firmware.md new file mode 100644 index 000000000..05fc04169 --- /dev/null +++ b/docs/user/firmware.md @@ -0,0 +1,106 @@ +--- +title: Firmware Updates +nav_order: 13 +last_updated: 2026-05-13 +description: Update your radio firmware over Bluetooth — OTA process, version channels, pre-flight checks, and recovery. +aliases: + - firmware + - update + - ota + - flash +--- + +# Firmware Updates + +Keep your Meshtastic radio up to date with the latest firmware for new features, bug fixes, and security improvements. + +## Checking for Updates + +1. Navigate to **Settings → Firmware Update** or tap the firmware notification if shown. +2. The app checks for available firmware versions. +3. Available updates show the version number and changelog summary. + +## Update Methods + +### OTA (Over-The-Air) via Bluetooth + +The most common update method for Android users: + +1. Ensure your radio is connected via Bluetooth. +2. Navigate to the Firmware Update screen. +3. Select the desired firmware version. +4. Tap **Update** to begin the OTA process. +5. Wait for the update to complete — **do not disconnect** during the update. + +![Firmware checking for updates](/assets/screenshots/firmware_checking.png) + +> ⚠️ **Warning:** Interrupting a firmware update can brick your device. Ensure your radio has sufficient battery (>50% recommended) and maintain Bluetooth proximity during the entire process. + +![Firmware disclaimer](/assets/screenshots/firmware_disclaimer.png) + +### USB Flashing + +For recovery or when OTA is unavailable: +- Use the [Meshtastic Web Flasher](https://flasher.meshtastic.org) +- Or the [Meshtastic CLI tool](https://meshtastic.org/docs/getting-started/flashing-firmware) on desktop + +## Version Channels + +| Channel | Description | +|---------|-------------| +| Stable | Recommended for most users; tested releases | +| Alpha | Preview releases; may contain bugs | + +## Pre-Update Checklist + +Before updating: +- [ ] Battery > 50% +- [ ] Stable Bluetooth connection +- [ ] Note your current settings (they may reset on major version changes) +- [ ] Check the release notes for breaking changes + +## Post-Update + +After a successful update: +- The radio will reboot automatically +- Bluetooth connection will re-establish +- Verify your settings are intact +- Check the firmware version in **Settings → About** + +![Firmware update success](/assets/screenshots/firmware_success.png) + +## Troubleshooting + +### Update Stuck + +If the update appears frozen: +- Wait at least 5 minutes before intervening +- If truly stuck, power-cycle the radio +- Attempt the update again + +### Device Won't Boot After Update + +If your device fails to boot: +1. Try connecting via USB to a computer +2. Use the web flasher in recovery/DFU mode +3. Flash a known-good firmware version +4. Check the Meshtastic Discord for device-specific recovery steps + +### Compatibility Warnings + +The app may show warnings when: +- Connected radio firmware is below minimum supported version +- Major version mismatch between app and firmware +- Deprecated features need migration + +> ⚠️ **Important:** Always update the Meshtastic app before or alongside firmware updates to ensure compatibility. + +## Related Topics + +- [Connections](connections) — reconnecting after a firmware update +- [Flashing firmware guide](https://meshtastic.org/docs/getting-started/flashing-firmware) — full firmware flashing walkthrough on meshtastic.org +- [Supported devices](https://meshtastic.org/docs/hardware/devices) — check firmware compatibility by device +- [FAQ](https://meshtastic.org/docs/about/faq) — common questions on meshtastic.org + +--- + diff --git a/docs/user/map-and-waypoints.md b/docs/user/map-and-waypoints.md new file mode 100644 index 000000000..7f9e8f1dc --- /dev/null +++ b/docs/user/map-and-waypoints.md @@ -0,0 +1,111 @@ +--- +title: Map & Waypoints +nav_order: 6 +last_updated: 2026-05-13 +description: View node positions on the map, create and share waypoints, and manage position sharing and privacy. +aliases: + - map + - waypoints + - gps + - location +--- + +# Map & Waypoints + +The Map screen shows the geographic positions of nodes on your mesh, along with shared waypoints. + +## Map View + +The map displays: +- **Node positions** — colored markers for each node reporting location +- **Waypoints** — shared points of interest +- **Your position** — your current GPS location + +### Node Markers + +Node markers on the map indicate: +| Color | Meaning | +|-------|---------| +| Green | Online (heard recently) | +| Yellow | Away (heard within 2 hours) | +| Gray | Offline (stale position) | +| Blue | Your own node | + +### Map Controls + +- **Zoom** — pinch or use +/- buttons +- **Pan** — drag to explore +- **Center** — select the location button to center on your position +- **Node tap** — tap a node marker to view details + +The floating toolbar provides quick access to compass, layer switching, node filters, refresh, and location tracking. Tap the compass to reorient north-up, or tap the location button to center on your current position. + +![Map controls overlay](/assets/screenshots/map_controls_overlay.png) + +## Waypoints + +Waypoints are shared geographic points of interest that all mesh members can see. + +### Creating a Waypoint + +1. Long-press on the map at the desired location. +2. Enter a name and optional description. +3. Choose an icon/emoji for the waypoint. +4. Tap **Send** to share with the mesh. + +### Waypoint Properties + +| Property | Description | +|----------|-------------| +| Name | Short identifier (max 30 characters) | +| Description | Optional longer description | +| Icon | Visual marker emoji on the map | +| Locked | If locked, only the creator can edit or delete | +| Expiration | Optional auto-remove time | + +### Waypoint Expiration + +Waypoints can be set to expire automatically: +- **Never** (default) — waypoint remains until manually deleted +- **Timed** — waypoint is automatically removed after the specified duration (e.g., "remove after 2 hours"). Useful for temporary markers like rally points, hazards, or meeting locations. + +Expired waypoints are automatically hidden from the map so they don't clutter the display. The expiration countdown begins when the waypoint is created, not when other nodes receive it. + +### Managing Waypoints + +- Tap a waypoint on the map to view its details and coordinates +- Edit or delete waypoints you created +- **Locked waypoints** cannot be modified or deleted by other nodes — only the original creator can change them +- Unlocked waypoints can be edited by any mesh member + +## Position Sharing + +### Enabling Position Sharing + +Your node shares its GPS position based on: +- **Fixed interval** — broadcast position at regular intervals +- **Smart position** — broadcast when movement exceeds a threshold +- **Manual** — only share when explicitly requested + +Configure position behavior in **Settings → Position**. + +### Privacy Considerations + +> 🔒 **Privacy:** Position data is broadcast to all nodes on your channel. If you don't want your location shared, disable GPS position in settings or use a fixed/fake position. + +## Map Sources + +The app supports multiple map tile sources: +- OpenStreetMap (default) +- Satellite imagery (where available) +- Offline tiles (download map areas for offline use) + +## Related Topics + +- [Nodes](nodes) — view and filter your node list +- [Node Metrics](node-metrics) — signal quality and position history for individual nodes +- [Discovery](discovery) — traceroute and neighbor info for understanding mesh topology +- [Units & Locale](units-and-locale) — distance and coordinate display formats + +--- + diff --git a/docs/user/messages-and-channels.md b/docs/user/messages-and-channels.md new file mode 100644 index 000000000..80a7195e5 --- /dev/null +++ b/docs/user/messages-and-channels.md @@ -0,0 +1,152 @@ +--- +title: Messages & Channels +nav_order: 3 +last_updated: 2026-05-13 +description: Send and receive messages, manage channels, configure encryption, and use quick chat, reactions, and message actions. +aliases: + - channels + - direct-messages + - messaging + - conversations +--- + +# Messages & Channels + +Meshtastic supports two communication modes: **channel broadcasts** and **direct messages**. + +## Channels + +Channels are shared communication groups. All nodes configured with the same channel key can read and send messages on that channel. + +### Default Channel + +Every Meshtastic device comes with a default **LongFast** channel. This is an unencrypted channel used for general mesh communication. + +### Channel Security + +Channels support multiple encryption levels: + +| Icon | Security Level | Description | +|------|----------------|-------------| +| 🔒 | PSK (256-bit AES) | Fully encrypted with a strong pre-shared key. Only nodes with the matching key can read messages. | +| 🔐 | PSK (128-bit AES) | Encrypted with a shorter key. Secure for most uses but 256-bit is preferred for sensitive data. | +| 🔓 | Default / Open | Uses the well-known default key. **Any Meshtastic device** on the same preset can read these messages. | +| ⚠️ | Insecure + Position | Open channel that also broadcasts your GPS position. Use with caution in public meshes. | + +> 🔒 **Security Tip:** Always configure a unique PSK for private communications. The default channel is intentionally open so new users can discover the mesh — but you should create a separate encrypted channel for anything sensitive. + +### Adding a Channel + +1. Navigate to **Settings → Channels**. +2. Tap **Add Channel** or scan a QR code. +3. Configure the channel name and encryption key. +4. Share the channel URL/QR code with others who need access. + +## Direct Messages + +Direct messages (DMs) are point-to-point encrypted communications between two specific nodes. + +### Sending a Direct Message + +1. Open the **Messages** tab. +2. Select a node from your contacts list or tap a node in the node list. +3. Type your message and tap **Send**. + +### Message States + +| State | Icon | Meaning | +|-------|------|---------| +| Queued | ⏳ | Message waiting to be sent | +| Sent | ✓ | Message transmitted to mesh | +| Delivered | ✓✓ | Acknowledgment received from recipient | +| Error | ✗ | Delivery failed after retries | + +### Delivery Errors + +When a message fails to deliver, the error indicator shows what went wrong: + +| Error | Meaning | What to Do | +|-------|---------|------------| +| No Route | No path exists to the destination node | The recipient may be offline or out of mesh range. Try later or move closer. | +| Got NAK | The next-hop node refused to relay | The relay node may be congested. Wait and retry. | +| Timeout | No acknowledgment within retry window | The recipient may be just out of range. Try increasing hop limit or moving to a better position. | +| No Interface | No radio interface available to send | Check that your radio is connected and the channel is configured. | +| Max Retransmit | All retry attempts exhausted | The mesh path is unreliable. Try a different channel or wait for conditions to improve. | +| No Channel | The destination channel doesn't exist | Verify both nodes share the same channel configuration. | +| Too Large | Message exceeds maximum payload size | Shorten your message (max ~230 characters). | +| No Response | Node received message but didn't respond | The recipient's radio may be busy or in low-power sleep mode. | +| Duty Cycle Limit | Regional airtime limit reached | Your radio has used its allowed transmit time. Wait for the duty cycle window to reset (typically 1 hour in EU regions). | +| Bad Request | Malformed or invalid message | This usually indicates a software bug. Try restarting the app. | + +> 💡 **Tip:** Most delivery errors resolve themselves. If a node is intermittently reachable, the mesh will retry. For persistent "No Route" errors, check that intermediate Router nodes are online. + +## Message Features + +### Quick Chat + +Pre-configured messages for rapid communication: +- Access via the Quick Chat button in the message input area +- Choose from built-in phrases or custom messages +- Customize quick chat messages in **Settings → Quick Chat** +- Useful when typing is impractical (gloves, small screen, urgent) + +![Quick chat option](/assets/screenshots/messages_quick_chat.png) + +The channel list shows each channel with its latest message preview: + +![Channel list item showing channel name and last message](/assets/screenshots/messages-and-channels_channel_list.png) + +### Message Bubbles + +Messages appear as chat bubbles — sent messages on the right, received messages on the left. Each bubble shows the sender, timestamp, and delivery status. Messages with replies include a quoted preview of the original message above the response. + +### Reactions + +React to messages with emoji: +- **Long-press** a message to open the actions menu +- Tap **Add Reaction** to choose an emoji +- Reactions appear below the message bubble +- Multiple users can react to the same message +- React to your own messages or others' messages + +![Emoji reaction badges displayed beneath a message](/assets/screenshots/messages_reaction.png) + +> 💡 **Tip:** Reactions are lightweight — they use minimal mesh bandwidth compared to full text messages. + +### Message Actions + +Long-press any message to access: +- **Copy** — copy message text to clipboard +- **Reply** — quote the message in your response +- **React** — add an emoji reaction +- **Delete** — remove a message you sent (local deletion) + +### Message Priority + +Messages are queued and transmitted based on priority: +1. Emergency/alert messages (highest) +2. Direct messages +3. Channel broadcasts (lowest) + +### Message Limits + +- **Maximum length:** 237 bytes (approximately 230 characters for ASCII text) +- **Rate limiting:** The mesh enforces airtime fairness; heavy message volume may be throttled +- **Delivery:** Messages are retried automatically if no acknowledgment is received + +## Best Practices + +- Use channels for group coordination +- Use direct messages for private person-to-person communication +- Keep messages short — mesh bandwidth is limited +- Configure encryption for sensitive communications + +## Related Topics + +- [Nodes](nodes) — tap a node to start a direct message +- [Settings — Radio & User](settings-radio-user) — configure channel encryption and presets +- [MQTT](mqtt) — bridge channel messages to the internet +- [Channel configuration](https://meshtastic.org/docs/configuration/radio/channels) — detailed channel settings on meshtastic.org + +--- + diff --git a/docs/user/mqtt.md b/docs/user/mqtt.md new file mode 100644 index 000000000..f79bffc3e --- /dev/null +++ b/docs/user/mqtt.md @@ -0,0 +1,135 @@ +--- +title: MQTT +nav_order: 11 +last_updated: 2026-05-13 +description: Bridge your mesh to the internet — MQTT broker setup, encryption layers, JSON output, and map reporting. +aliases: + - mqtt + - internet-bridge + - broker +--- + +# MQTT + +MQTT bridges your Meshtastic mesh network to the internet, enabling long-range communication beyond radio range. + +## Overview + +The MQTT module connects your node to an MQTT broker, allowing: +- Messages to reach nodes on different physical meshes via the internet +- Integration with home automation and monitoring systems +- Publishing node positions to the public Meshtastic map +- Custom data pipelines for logging and alerting + +## How It Works + +``` +[Your Node] → Radio → [Gateway Node with WiFi] → MQTT Broker → [Remote Gateway] → Radio → [Remote Node] +``` + +A gateway node with internet access (WiFi or Ethernet) publishes mesh messages to an MQTT topic. Remote gateways subscribed to the same topic inject those messages into their local mesh. + +## Configuration + +### Enabling MQTT + +1. Navigate to **Settings → Module Config → MQTT**. +2. Enable the MQTT module. +3. Configure the broker connection: + +![MQTT toggle switch](/assets/screenshots/settings_switch.png) + +| Setting | Description | Default | +|---------|-------------|---------| +| Server Address | MQTT broker hostname | mqtt.meshtastic.org | +| Username | Broker authentication | meshdev | +| Password | Broker authentication | large4cats | +| Root Topic | Base topic for messages | msh | +| Encryption | Encrypt MQTT payload | Enabled | +| JSON Output | Publish JSON alongside protobuf | Disabled | +| TLS | Secure connection to broker | Disabled | +| Map Reporting | Report position to public map | Disabled | + +### Default Meshtastic Broker + +The community maintains a public broker at `mqtt.meshtastic.org`. This is intended for general use and testing. + +> 🔒 **Privacy:** Messages on the public broker are readable by anyone subscribed. Always use channel encryption for private communications. + +### Private Broker + +For better privacy and control, you can run your own MQTT broker: +- Mosquitto (lightweight, open-source) +- HiveMQ +- EMQX + +Configure your node to point to your private broker with appropriate credentials. + +## Map Reporting + +When Map Reporting is enabled, your node publishes its position to the Meshtastic community map: +- Visible at [meshmap.net](https://meshmap.net) and similar community map services +- Only position and node info are shared +- Disable this if you don't want your location publicly visible + +## Uplink vs Downlink + +| Direction | Description | +|-----------|-------------| +| **Uplink** | Messages from mesh → MQTT broker | +| **Downlink** | Messages from MQTT broker → mesh | + +Configure per-channel which directions are active to control message flow and airtime usage. + +## Message Formats + +MQTT supports two message formats: + +| Format | Description | Use case | +|--------|-------------|----------| +| **Protobuf** (default) | Binary Meshtastic protobuf encoding | Node-to-node mesh bridging | +| **JSON** | Human-readable JSON encoding | Home automation, logging, custom integrations | + +When **JSON Output** is enabled, the gateway publishes both protobuf and JSON versions of each message to separate topics. + +## Encryption & Privacy + +Understanding the layered encryption model: + +1. **Channel encryption** happens on the mesh *before* MQTT. If your channel has a PSK, the MQTT payload is already encrypted — the broker and any subscribers see only the ciphertext. +2. **MQTT encryption** (the module setting) adds an additional encryption layer for transit to the broker. This protects metadata and routing information. +3. **TLS** encrypts the TCP connection to the broker itself, preventing network-level eavesdropping. + +> 🔒 **Important:** The default public channel has a well-known key. Messages on the default channel sent via MQTT are effectively **unencrypted** — anyone can decode them. Always use a custom PSK for private communications. + +## Best Practices + +- Use channel-level encryption (PSK) on channels that bridge to MQTT +- Don't enable MQTT on nodes without internet access (it will buffer and waste memory) +- Use a private broker for sensitive deployments +- Be mindful of airtime when downlinking messages from busy MQTT topics — every downlinked message consumes radio airtime on your local mesh +- Consider enabling uplink-only if you only need to monitor your mesh remotely without injecting messages back + +## Troubleshooting + +### MQTT Not Connecting + +- **Check WiFi** — the gateway node must have an active internet connection (WiFi or Ethernet). MQTT does not work over the LoRa radio link itself. +- **Verify credentials** — incorrect username or password will silently fail on most brokers. Double-check for trailing spaces. +- **Firewall** — port 1883 (MQTT) or 8883 (MQTT+TLS) must be open. Some networks block non-standard ports. +- **DNS resolution** — if using a custom broker hostname, verify the node can resolve it. Try the broker's IP address directly. + +### Messages Not Bridging + +- **Check uplink/downlink settings** — if only uplink is enabled, messages flow from mesh to MQTT but not back. Enable downlink on the receiving gateway. +- **Channel mismatch** — both gateways must share the same channel with the same PSK. A mismatch means messages are encrypted with different keys and appear as garbage. +- **Topic mismatch** — ensure both gateways use the same root topic. The default `msh` works for the public broker. + +## Related Topics + +- [Settings — Modules & Admin](settings-module-admin) — MQTT module configuration reference +- [Messages & Channels](messages-and-channels) — channel encryption and PSK setup +- [MQTT integration guide](https://meshtastic.org/docs/software/integrations/mqtt) — detailed MQTT documentation on meshtastic.org + +--- + diff --git a/docs/user/node-metrics.md b/docs/user/node-metrics.md new file mode 100644 index 000000000..e36b7cf38 --- /dev/null +++ b/docs/user/node-metrics.md @@ -0,0 +1,129 @@ +--- +title: Node Metrics +nav_order: 5 +last_updated: 2026-05-13 +description: Telemetry dashboards for each mesh node — device health, environment sensors, signal quality, power, traceroute, and position history. +aliases: + - metrics + - telemetry + - device-metrics + - signal +--- + +# Node Metrics + +The node detail screen provides comprehensive telemetry and metrics for each node on your mesh. + +## Device Metrics + +Basic operating information reported by each node: + +| Metric | Description | +|--------|-------------| +| Battery Level | Current battery percentage | +| Voltage | Battery voltage reading | +| Channel Utilization | Percentage of airtime consumed | +| Airtime | Transmission time used by this node | +| Uptime | Time since last reboot | + +Device metrics are displayed as individual cards with trend sparklines showing battery level, voltage, channel utilization, airtime, and uptime over time. + +> 💡 **Tip:** Tap any metric card to expand it into a full chart with historical data points. Pinch to zoom the time axis. + +## Environment Metrics + +Environmental sensor data (requires compatible hardware): + +| Metric | Sensor Examples | +|--------|-----------------| +| Temperature | BME280, BME680, SHT31 | +| Humidity | BME280, BME680, SHT31 | +| Barometric Pressure | BME280, BMP280 | +| Gas Resistance | BME680 | +| IAQ (Air Quality) | BME680 | + +Environment metrics are charted over time for easy trend analysis — temperature, humidity, and pressure each get their own line chart with the measurement unit displayed on the Y axis. + +> 💡 **Tip:** Environment metrics require a sensor connected to the remote node. Not all nodes report environmental data. See [Telemetry & Sensors](telemetry-and-sensors) for a full list of supported sensors. + +## Signal Metrics + +Radio signal quality information: + +| Metric | Description | +|--------|-------------| +| SNR | Signal-to-Noise Ratio (higher is better) | +| RSSI | Received Signal Strength Indicator (closer to 0 is better) | +| Hop Count | Number of mesh hops for last message | + +### Signal Quality Reference + +| SNR Range | Quality | +|-----------|---------| +| > 10 dB | Excellent | +| 0 to 10 dB | Good | +| -10 to 0 dB | Fair | +| < -10 dB | Poor | + +## Power Metrics + +Power management telemetry (requires INA sensor or compatible hardware): + +| Metric | Description | +|--------|-------------| +| Bus Voltage | Supply voltage | +| Current | Power draw in milliamps | +| Power | Calculated wattage | + +## Traceroute + +Traceroute shows the path a message takes through the mesh: + +1. From the node detail screen, tap **Traceroute**. +2. The app sends a traceroute request to the target node. +3. Results show each hop with SNR/RSSI values. + +### Reading Traceroute Results + +``` +You → Node A (SNR: 8.5) → Node B (SNR: 5.2) → Target +``` + +Each hop represents a relay node that forwarded the message. + +## Position Log + +Historical position data for nodes that share their location: +- GPS coordinates +- Altitude +- Speed (if moving) +- Timestamp for each position report + +## Neighbor Info + +Shows which nodes a given node can directly hear, useful for understanding mesh topology. + +## Viewing Metrics + +1. Navigate to **Nodes**. +2. Tap the node you want to inspect. +3. Select the metric category from the detail tabs. + +![Node detail — local device](/assets/screenshots/nodes_detail_local.png) + +The position tab shows location data for nodes that share GPS: + +![Position inline content](/assets/screenshots/nodes_position.png) + +> ⚠️ **Note:** Metrics are only available when they have been reported by the remote node. Metrics update at intervals configured on each node's telemetry settings. + +## Related Topics + +- [Nodes](nodes) — node list, filtering, and sorting +- [Telemetry & Sensors](telemetry-and-sensors) — supported sensors and configuration +- [Signal Meter](signal-meter) — how signal quality is calculated from SNR and RSSI +- [Discovery](discovery) — traceroute details and neighbor info +- [Units & Locale](units-and-locale) — temperature, distance, and speed display formats + +--- + diff --git a/docs/user/nodes.md b/docs/user/nodes.md new file mode 100644 index 000000000..b66a94b6e --- /dev/null +++ b/docs/user/nodes.md @@ -0,0 +1,148 @@ +--- +title: Nodes +nav_order: 4 +last_updated: 2026-05-13 +description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions. +aliases: + - node-list + - mesh-nodes + - peers +--- + +# Nodes + +The Nodes screen displays all devices visible on your mesh network. + +## Node List + +The node list shows every node your radio has heard, including: +- **Node name** — user-configured long name +- **Short name** — 4-character identifier +- **Signal quality** — last heard signal strength +- **Last heard** — time since last communication +- **Distance** — estimated distance (if positions are shared) +- **Battery** — remote node battery level (if telemetry is enabled) + +### Node Status Indicators + +| Badge | Meaning | +|-------|---------| +| 🟢 Online | Node heard within the last 15 minutes | +| 🟡 Away | Node heard within the last 2 hours | +| 🔴 Offline | Node not heard for over 2 hours | +| ⭐ Favorite | Node marked as favorite by the user | + +### Node Roles + +Nodes can be configured with different roles that affect their mesh behavior: + +| Role | Description | +|------|-------------| +| Client | Standard end-user device | +| Client Mute | Receives but doesn't retransmit | +| Client Hidden | Like Client Mute, plus hides from node list | +| Router | Prioritizes message forwarding; stays awake to relay | +| Router Client | Routes and operates as a client | +| Repeater | Retransmits only; no user interface | +| Tracker | Optimized for position reporting at regular intervals | +| Sensor | Optimized for telemetry reporting | +| TAK | Interoperates with TAK systems (sends/receives CoT) | +| TAK Tracker | TAK position reporting only | +| Lost & Found | Continuous position beacon for recovery | + +### Choosing a Role + +Most users should keep the default **Client** role. Consider a different role when: + +- **Router** — You have a node in a fixed, elevated location with reliable power (rooftop, hilltop). Routers stay awake continuously to relay messages for others and are essential for extending mesh coverage. Don't use Router on battery-powered handheld devices. +- **Router Client** — Like Router, but the device is also used as a personal client. Good for a home base station that you also send messages from. +- **Client Mute** — You want to receive mesh traffic but not contribute to relaying. Useful for monitoring-only devices or to reduce congestion in dense areas. +- **Repeater** — A dedicated relay node with no screen or user interaction. Optimized purely for forwarding; lowest power consumption of the relay roles. +- **Tracker** — An unattended device whose sole purpose is broadcasting its GPS position (e.g., a vehicle, pet, or asset). Sleeps between broadcasts to conserve battery. +- **Sensor** — An unattended device reporting environmental telemetry (temperature, humidity, air quality). Similar power profile to Tracker. +- **TAK / TAK Tracker** — Only needed if interoperating with ATAK/WinTAK systems. See [TAK Integration](tak) for details. + +> 💡 **Tip:** The mesh works best when most nodes are **Client** or **Router**. Too many Mute nodes reduces mesh resilience; too many Routers in a dense area can cause congestion. A good rule of thumb: one Router per 5–10 Clients in your area. + +### Encryption Indicators + +Nodes display encryption status icons next to their name: + +| Icon | Meaning | +|------|---------| +| 🔒 Locked | Communication uses PKI (public key infrastructure) — end-to-end encrypted with verified identity | +| 🔓 Unlocked | Communication uses shared channel PSK — encrypted but identity not individually verified | +| ⚠️ Mismatch | Public key mismatch — the node's key has changed since last seen (investigate before trusting) | + +> 💡 **Tip:** PKI encryption (firmware 2.5+) provides stronger security than channel PSK because each node has a unique key pair. If you see a key mismatch warning, the node may have been reset or compromised. + +## Quick Actions + +From the node list, you can: +- **Tap** a node to view its detail page +- **Long-press** for quick actions: + - Send a direct message + - View on map + - Request position + - Mark as favorite + - Traceroute + +## Filtering & Sorting + +### Text Search + +Type in the search field to filter nodes by name or short name. The filter updates in real time as you type. + +### Filter Toggles + +| Filter | Description | +|--------|-------------| +| **Only online** | Show only nodes heard within the last 15 minutes | +| **Only direct** | Show only nodes with direct (non-relayed) connections | +| **Include unknown** | Show nodes that haven't sent user info yet | +| **Exclude infrastructure** | Hide infrastructure-role nodes (Router, Repeater, Router Client) | +| **Exclude MQTT** | Hide nodes heard only via MQTT internet bridge | +| **Show ignored** | Show nodes you've previously dismissed or muted | + +### Sort Options + +| Sort | Description | +|------|-------------| +| **Last heard** (default) | Most recently heard nodes first | +| **Alphabetical** | Sorted by node long name | +| **Distance** | Nearest nodes first (requires position sharing) | +| **Hops away** | Fewest relay hops first | +| **Channel** | Grouped by channel index | +| **Via MQTT** | Grouped by MQTT vs. radio-heard | +| **Favorites** | Favorited nodes first | + +## Node Detail + +Tapping a node opens the detail view with comprehensive information. See [Node Metrics](node-metrics) for full details on metrics and telemetry. + +![Node detail view](/assets/screenshots/nodes_node_list.png) + +The detail screen includes device info, position, and action buttons: + +![Node detail section](/assets/screenshots/nodes_detail_section.png) + +Inline status indicators show key metrics at a glance: + +| Indicator | Screenshot | +|-----------|------------| +| Signal quality | ![Signal](/assets/screenshots/nodes_signal_info.png) | +| Battery level | ![Battery](/assets/screenshots/nodes_battery_info.png) | +| Hop count | ![Hops](/assets/screenshots/nodes_hops_info.png) | +| Last heard | ![Last heard](/assets/screenshots/nodes_last_heard.png) | +| Distance | ![Distance](/assets/screenshots/nodes_distance_info.png) | + +## Related Topics + +- [Node Metrics](node-metrics) — detailed telemetry dashboards for each node +- [Messages & Channels](messages-and-channels) — send a direct message to a node +- [Map & Waypoints](map-and-waypoints) — view node positions geographically +- [Discovery](discovery) — traceroute and neighbor info for topology exploration +- [Signal Meter](signal-meter) — understand what the signal bars mean + +--- + diff --git a/docs/user/onboarding.md b/docs/user/onboarding.md new file mode 100644 index 000000000..11e64d42f --- /dev/null +++ b/docs/user/onboarding.md @@ -0,0 +1,92 @@ +--- +title: Getting Started +nav_order: 1 +last_updated: 2026-05-13 +description: First-launch setup — permissions, onboarding flow, and next steps after connecting your radio. +aliases: + - first-launch + - setup + - intro +--- + +# Getting Started + +Welcome to Meshtastic! This guide walks you through the initial setup of the Meshtastic Android app. + +## First Launch + +When you open the app for the first time, you'll be guided through an introductory flow that helps configure essential permissions and settings. Each step can be completed in order, or you can skip and configure permissions later in Android settings. + +### Welcome Screen + +The welcome screen introduces Meshtastic and its core capabilities: +- Off-grid mesh communication +- No cellular or internet required +- End-to-end encrypted messaging + +Tap **Get Started** to proceed through the setup flow. + +![Welcome screen](/assets/screenshots/onboarding_welcome.png) + +## Permissions + +The app requests several permissions during setup. Each one serves a specific purpose, and some are required for core functionality. + +### Bluetooth Permission + +Bluetooth is the primary connection method between your phone and Meshtastic radio: +- **Bluetooth scanning** — discover nearby Meshtastic radios +- **Bluetooth connect** — establish and maintain connections with paired radios + +Grant both permissions when prompted. Without Bluetooth, you'll need to use USB or TCP connections instead. + +### Location Permission + +> ⚠️ **Why is location required for Bluetooth?** Android requires location permission to discover nearby Bluetooth Low Energy devices. This is an Android system requirement, not a Meshtastic-specific choice. + +Meshtastic also uses your location for: +- Showing your position on the mesh map +- Calculating distances to other nodes +- Sharing your GPS coordinates with other mesh members (if enabled) + +Grant **"While using the app"** or **"Always"** depending on your preference: +- **While using the app** — position updates only when the app is open +- **Always** — enables background position updates for always-on mesh presence + +If denied, Bluetooth scanning will not function and your node will not report a position. + +### Notifications Permission + +Notifications alert you to: +- Incoming messages from channels and direct messages +- Connection status changes (connected, disconnected, reconnecting) +- Firmware update availability + +> 💡 **Tip:** You can fine-tune notification preferences later in Android system settings. The app creates separate notification channels for messages, connection events, and background service status. + +### Critical Alerts Permission + +On supported devices, the app may request permission for critical alerts: +- These are high-priority notifications that can break through Do Not Disturb mode +- Useful for emergency mesh alerts or urgent messages +- You can **skip** this step if you don't need breakthrough notifications +- Configure or revoke later in Android notification settings + +## After Setup + +Once permissions are granted, the app transitions to the main interface. Your first action should be connecting to a Meshtastic radio — see [Connections](connections) for detailed instructions. + +> 💡 **Tip:** If you skipped any permissions during setup, you can grant them later through **Android Settings → Apps → Meshtastic → Permissions**. The app will prompt you again if a missing permission blocks a feature you try to use. + +## What's Next? + +Once connected to a radio, explore: +- [Connections](connections) — pair your first radio device +- [Messages & Channels](messages-and-channels) — send your first message +- [Nodes](nodes) — see who's on your mesh +- [Map & Waypoints](map-and-waypoints) — view node positions +- [Settings](settings-radio-user) — configure your radio and user profile + +New to Meshtastic? The [getting started guide](https://meshtastic.org/docs/getting-started) on meshtastic.org covers hardware selection, initial radio configuration, and your first mesh setup. + +--- diff --git a/docs/user/settings-module-admin.md b/docs/user/settings-module-admin.md new file mode 100644 index 000000000..c866ef08f --- /dev/null +++ b/docs/user/settings-module-admin.md @@ -0,0 +1,241 @@ +--- +title: Settings — Modules & Admin +nav_order: 8 +last_updated: 2026-05-13 +description: Configure optional feature modules (MQTT, telemetry, canned messages, TAK, and more) and perform device administration. +aliases: + - modules + - module-config + - administration +--- + +# Settings — Modules & Admin + +Configure optional feature modules and perform device administration. Modules extend Meshtastic with specialized capabilities — each can be independently enabled or disabled. + +> 💡 **Tip:** You only need to enable the modules you actually use. Disabling unused modules reduces airtime, saves battery, and simplifies your configuration. + +Module settings use a card-based layout with toggle switches, dropdowns, text fields, and sliders: + +![Toggle switch](/assets/screenshots/settings_switch.png) + +![Dropdown selector](/assets/screenshots/settings_dropdown.png) + +## Module Configuration + +### MQTT Module + +Bridges mesh messages to and from an MQTT broker for internet connectivity. This is how you extend your mesh beyond radio range or integrate with home automation systems. + +| Setting | Description | +|---------|-------------| +| Enabled | Toggle MQTT bridge | +| Server | MQTT broker address | +| Username | Authentication username | +| Password | Authentication password | +| Encryption | Encrypt MQTT payloads | +| JSON Output | Also publish in JSON format | +| TLS | Use secure connection | +| Root Topic | Base MQTT topic path | +| Map Report | Publish position for public map | + +See [MQTT](mqtt) for a detailed usage guide including encryption, privacy, and broker setup. + +### Serial Module + +Enables serial port communication for external device integrations (GPS modules, sensors, or custom hardware). When enabled, the node's serial port can send and receive protobuf or text data, allowing external microcontrollers or computers to interact with the mesh. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate serial communication | +| Echo | Echo received serial data back | +| Mode | Text, Protobuf, or NMEA output | +| RX/TX Pins | GPIO pins for serial connection | +| Baud Rate | Serial communication speed | + +### External Notification Module + +Controls buzzer, LED, or vibration alerts on your radio hardware. Useful for devices that need to physically signal when a message arrives — particularly helpful for unattended or outdoor installations. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate notifications | +| Alert Message | Notify on incoming messages | +| Alert Message Buzzer | Use buzzer for messages | +| Alert Message Vibra | Use vibration for messages | +| Alert Bell | Notify on bell character | +| Output (GPIO) | Pin for notification output | +| Active | High or Low active | +| Duration (ms) | Notification length | +| Use I2S as Buzzer | Use I2S audio output | + +### Store & Forward Module + +Buffers messages for nodes that were temporarily offline, then replays them when those nodes reconnect. Essential for meshes where nodes go in and out of range regularly — ensures messages aren't lost during brief disconnections. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate store and forward | +| Heartbeat (s) | Announcement interval | +| Records | Maximum stored messages | +| History Return (max) | Max messages to replay | +| History Return (window) | Time window for replay | + +> 💡 **Tip:** Store and Forward works best on nodes with ample memory (ESP32 with PSRAM). Router nodes are ideal candidates since they're typically always-on. + +### Range Test Module + +Automated range testing tool for evaluating link quality between nodes. When enabled, the node periodically transmits test messages with incrementing counters. A receiver node logs these messages, allowing you to walk or drive away and later analyze at what distance messages stopped arriving. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate range testing | +| Sender Interval (s) | Time between test transmissions | +| Save CSV | Log received test data to SD card | + +### Telemetry Module + +Controls what telemetry data your node shares with the mesh. Telemetry includes device health (battery, uptime) and environmental sensor data (temperature, humidity, pressure). + +| Setting | Description | +|---------|-------------| +| Device Metrics Interval | How often to report device metrics | +| Environment Metrics Interval | How often to report environment sensors | +| Air Quality Enabled | Report particulate sensor data | +| Power Metrics Enabled | Report power usage | + +See [Telemetry & Sensors](telemetry-and-sensors) for supported sensors and configuration recommendations. + +### Canned Message Module + +Pre-configured messages accessible from the device's physical buttons (for radios with rotary encoders, keypads, or similar input hardware). Define a list of quick-send messages that can be transmitted without a phone connected — ideal for field use. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate canned messages | +| Messages | Newline-separated list of messages | +| Send Bell | Play bell sound on send | +| Rotary Encoder | Enable rotary encoder input | +| Up/Down/Press Pins | GPIO pin assignments for input | + +### Audio Module + +Codec2 audio support for low-bandwidth voice communication over the mesh. This is an **experimental** feature that encodes voice into very small data packets using the Codec2 codec. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate audio module | +| Codec2 Rate | Audio quality/bandwidth tradeoff | +| I2S Word Select | GPIO pin for I2S WS | +| I2S Data In | GPIO pin for I2S DIN | +| I2S Data Out | GPIO pin for I2S DOUT | + +> ⚠️ **Note:** Audio requires specific hardware (I2S microphone and speaker). Voice quality is very low-bandwidth — think "understandable radio voice," not phone-call quality. + +### Remote Hardware Module + +GPIO control over the mesh network. Allows a remote node to read or write GPIO pins on another node — useful for activating relays, reading switches, or controlling external hardware from a distance. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate remote GPIO access | +| Allow Undefined Pins | Allow access to any GPIO pin (security risk) | + +> ⚠️ **Warning:** Enabling "Allow Undefined Pins" gives remote nodes access to all GPIO pins, which could interfere with the radio's own hardware. Only enable on dedicated GPIO nodes. + +### Neighbor Info Module + +Broadcasts information about directly heard neighbors, enabling mesh topology mapping. Each enabled node periodically shares a list of the other nodes it can hear and their signal quality. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate neighbor broadcasting | +| Update Interval (s) | How often to broadcast neighbor list | + +See [Discovery](discovery) for how to use neighbor data for mesh topology exploration. + +### Ambient Lighting Module + +Controls onboard NeoPixel or other addressable RGB LEDs on supported hardware. Can be used for visual status indicators, notification lights, or decorative effects. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate LED control | +| LED State | On, Off, or set specific color | +| Red / Green / Blue | Individual color channel values (0–255) | + +### Detection Sensor Module + +Turns your node into a motion or door sensor alert system. When a GPIO pin detects a state change (motion detected, door opened), the node broadcasts an alert message over the mesh. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate detection sensor | +| Monitor Pin | GPIO pin connected to sensor | +| Detection Triggered High | Trigger when pin goes high (vs. low) | +| Minimum Broadcast (s) | Minimum time between alert broadcasts | +| State Broadcast (s) | Periodic state broadcast interval | +| Send Bell | Include bell character in alerts | +| Friendly Name | Custom name for this sensor | + +### Paxcounter Module + +People counter using WiFi and BLE probe requests. Counts nearby devices by passively listening for probe requests that phones and laptops emit when scanning for networks. Available only on ESP32 devices. + +| Setting | Description | +|---------|-------------| +| Enabled | Activate people counting | +| Update Interval (s) | How often to report counts | + +> 💡 **Tip:** Paxcounter is useful for estimating foot traffic at trailheads, event venues, or other locations. Counts are approximate — one person may carry multiple devices. + +### TAK Module + +Team Awareness Kit integration for interoperability with ATAK and WinTAK. See [TAK Integration](tak) for detailed setup and usage. + +## Administration + +### Remote Administration + +Remotely configure nodes that share your admin key: + +1. Select the target node in the node list. +2. Navigate to **Settings** for that node. +3. Modify configuration. +4. Tap **Save** — changes are sent over the mesh. + +> ⚠️ **Requires:** Admin key configured on both your node and the target node. + +### Clean Node Database + +Removes stale nodes from your local database that haven't been heard in a configurable time window. + +### Factory Reset + +Resets all settings to factory defaults. **This cannot be undone.** + +### Reboot + +Remotely reboot a connected or administered node. + +### Debug Panel + +View detailed diagnostic information: +- Protocol buffers debug output +- Mesh packet log +- Connection state details + +### Troubleshooting Remote Admin + +- **"No response from target node"** — the target may be out of range, offline, or have a mismatched admin key. Verify the admin key matches on both nodes. +- **Changes not applying** — some settings require a reboot to take effect. Try the Reboot action after saving. +- **Can't see remote settings** — ensure your node has the admin key for the target node and that Admin Channel is enabled in Security Config. + +## Related Topics + +- [Settings — Radio & User](settings-radio-user) — core radio and user profile settings +- [Module configuration reference](https://meshtastic.org/docs/configuration/module) — detailed module docs on meshtastic.org +- [FAQ](https://meshtastic.org/docs/about/faq) — common questions on meshtastic.org + +--- + diff --git a/docs/user/settings-radio-user.md b/docs/user/settings-radio-user.md new file mode 100644 index 000000000..7bed4a4d8 --- /dev/null +++ b/docs/user/settings-radio-user.md @@ -0,0 +1,163 @@ +--- +title: Settings — Radio & User +nav_order: 7 +last_updated: 2026-05-13 +description: Configure your radio hardware, LoRa presets, user profile, position sharing, power management, and security. +aliases: + - settings + - radio-config + - user-config + - lora +--- + +# Settings — Radio & User + +Configure your radio hardware and user identity parameters. + +## User Settings + +### User Profile + +| Setting | Description | +|---------|-------------| +| Long Name | Your display name (up to 39 characters) | +| Short Name | 4-character abbreviated name | +| Licensed Operator | Enable if you hold an amateur radio license (enables higher power) | + +### Applying Changes + +After modifying settings, tap **Save** to write the configuration to your radio. The device may reboot to apply changes. + +![Settings appearance section](/assets/screenshots/settings-radio-user_lora_config.png) + +## Radio Configuration + +### Device Config + +| Setting | Description | Default | +|---------|-------------|---------| +| Role | Node behavior (Client, Router, etc.) | Client | +| Serial Output | Enable serial console output | Disabled | +| Debug Log | Enable verbose debug logging | Disabled | +| Rebroadcast Mode | How the node retransmits messages | All | +| Node Info Broadcast (s) | Interval for broadcasting node info | 10800 | +| Double-tap Button | Action for double-tap button press | Disabled | + +### LoRa Config + +| Setting | Description | Default | +|---------|-------------|---------| +| Region | Regulatory region for frequency bands | Unset (must configure) | +| Modem Preset | Speed/range tradeoff | LongFast | +| Hop Limit | Maximum retransmit hops | 3 | +| TX Power | Transmission power (dBm); 0 = max allowed for region | 0 (region max) | +| Frequency Offset | Fine-tune frequency (MHz) | 0 | +| Channel Bandwidth | Bandwidth setting | Default for preset | + +> ⚠️ **Important:** You **must** set your region before transmitting. Operating without the correct region may violate local radio regulations. See the [region configuration guide](https://meshtastic.org/docs/getting-started/initial-config) on meshtastic.org for details. + +### Modem Presets + +| Preset | Range | Speed | SNR Limit | Best For | +|--------|-------|-------|-----------|----------| +| Short Turbo | ~1 km | 21.9 kbps | −5 dB | Dense urban with line-of-sight; data-heavy applications | +| Short Fast | ~3 km | 10.9 kbps | −7.5 dB | Urban neighborhoods; buildings within a few blocks | +| Medium Fast | ~5 km | 5.5 kbps | −10 dB | Suburban areas; moderate building density | +| Long Fast | ~10 km | 1.1 kbps | −12.5 dB | **General use (default)** — balanced range and speed | +| Long Moderate | ~20 km | 0.34 kbps | −15 dB | Rural with some terrain; occasional use | +| Long Slow | ~30 km | 0.18 kbps | −17.5 dB | Sparse rural; maximum reliable range | +| Very Long Slow | ~40+ km | 0.09 kbps | −20 dB | Extreme range experiments; very slow throughput | + +#### Choosing a Modem Preset + +The modem preset controls the fundamental tradeoff between **range** and **data rate**: + +- **Slower presets** use more spreading, making signals decodable at weaker signal levels (lower SNR limit). This means longer range but fewer bytes per second. +- **Faster presets** pack more data per transmission but require a stronger signal to decode. + +**Practical guidance:** + +- **Urban mesh (many nodes, short distances):** Use **Long Fast** (default) or **Short Fast**. Higher speed means less airtime congestion when many nodes share the channel. +- **Rural/sparse mesh (few nodes, long distances):** Use **Long Moderate** or **Long Slow**. Range matters more than speed when nodes are far apart. +- **Fixed infrastructure links:** Use **Short Turbo** for dedicated point-to-point links with good antennas and line-of-sight. +- **Mixed environments:** Stick with **Long Fast** — it's the community default and ensures compatibility with others in your area. + +> ⚠️ **Important:** All nodes on the same channel **must** use the same modem preset. Nodes with mismatched presets cannot communicate even if they share the same frequency and encryption key. + +> 💡 **Tip:** The range estimates above assume flat terrain and modest antennas. Elevation advantage (hilltop, rooftop) dramatically increases effective range. A well-placed Router with Long Fast can often outperform a ground-level node with Long Slow. + +### Display Config + +| Setting | Description | +|---------|-------------| +| Screen Timeout | Time before display sleeps | +| Display Units | Metric or Imperial | +| GPS Format | DMS, Decimal, UTM, MGRS, OLC | +| OLED Type | Auto, SSD1306, SH1106, SH1107 | +| Compass North | True North or Magnetic North | + +### Position Config + +| Setting | Description | +|---------|-------------| +| GPS Enabled | Enable/disable GPS | +| GPS Update Interval | How often to acquire GPS fix | +| Position Broadcast (s) | How often to share position | +| Smart Position | Enable movement-based broadcasting | +| Fixed Position | Use a manually set position | + +### Power Config + +| Setting | Description | +|---------|-------------| +| Power Saving | Enable low-power sleep mode | +| Shutdown After (s) | Auto-shutdown idle timer | +| ADC Multiplier | Battery voltage calibration factor | +| Wait Bluetooth (s) | Time to wait for BLE connection at boot | +| Mesh SDS Timeout (s) | Super-deep-sleep timeout | + +### Network Config + +| Setting | Description | +|---------|-------------| +| WiFi Enabled | Enable WiFi radio (ESP32 devices) | +| WiFi SSID | Network name to connect to | +| WiFi PSK | Network password | +| NTP Server | Time synchronization server | +| Syslog Server | Remote logging server | + +### Bluetooth Config + +| Setting | Description | +|---------|-------------| +| Bluetooth Enabled | Enable/disable BLE radio | +| Pairing Mode | Fixed PIN, Random PIN, or No PIN | +| Fixed PIN | PIN code for pairing (default: 123456) | + +### Security Config + +| Setting | Description | +|---------|-------------| +| Public Key | Your node's public key (read-only) | +| Admin Key | Key for remote administration | +| Private Key | Your node's private key (handle securely) | +| Admin Channel Enabled | Allow admin commands via channel | +| Managed Mode | Restrict non-admin channel changes | + +Settings use standard preference controls — dropdowns, toggles, and sliders: + +| Control | Screenshot | +|---------|------------| +| Dropdown | ![Dropdown](/assets/screenshots/settings_dropdown.png) | +| Toggle | ![Toggle](/assets/screenshots/settings_switch.png) | +| Slider | ![Slider](/assets/screenshots/settings_slider.png) | + +## Related Topics + +- [Settings — Modules & Admin](settings-module-admin) — optional feature modules and device administration +- [Signal Meter](signal-meter) — how modem presets affect signal quality thresholds +- [LoRa configuration](https://meshtastic.org/docs/configuration/radio/lora) — detailed LoRa settings reference on meshtastic.org +- [Initial configuration](https://meshtastic.org/docs/getting-started/initial-config) — region setup guide on meshtastic.org + +--- + diff --git a/docs/user/signal-meter.md b/docs/user/signal-meter.md new file mode 100644 index 000000000..3f573ca4d --- /dev/null +++ b/docs/user/signal-meter.md @@ -0,0 +1,80 @@ +--- +title: How the Meshtastic Signal Meter Works +nav_order: 15 +last_updated: 2026-05-13 +description: How the signal meter calculates quality from RSSI and SNR — LoRa spread spectrum, presets, and what the bars really mean. +aliases: + - signal + - signal-meter + - snr + - rssi +--- + +# How the Meshtastic Signal Meter Works + +The Meshtastic signal meter — the familiar bars or status color in the app — is calculated very differently than the "bars" on a traditional cell phone or Wi-Fi router. + +Most consumer devices simply measure how "loud" a signal is. However, because Meshtastic uses **LoRa (Long Range)** technology, its signal meter measures how **clear** the signal is, relative to the specific settings your mesh is using. + +--- + +## 1. The Two Metrics: "Loudness" vs. "Clarity" + +Every time the LoRa radio chip receives a message, it reports two measurements: + +* **RSSI (Received Signal Strength Indicator):** The **loudness** of the raw power hitting your antenna. +* **SNR (Signal-to-Noise Ratio):** The **clarity** of the signal compared to the background static. + +> **Tip — The Analogy:** Imagine you are trying to hear a friend talking to you. +> * **RSSI** is how loud their voice is. +> * **The Noise Floor** is the background noise in the room (air conditioning, other people talking, traffic). +> * **SNR** is how easily you can distinguish your friend's voice from the background noise. + +If your friend shouts at you at a deafening rock concert, the signal is incredibly loud (High RSSI), but you still can't understand them because the background noise is louder (Bad SNR). Conversely, if your friend whispers to you in a dead-silent library, the signal is very weak (Low RSSI), but you can understand them perfectly (Great SNR). + +--- + +## 2. The Magic of LoRa: Hearing "Below the Noise Floor" + +For standard radios (like FM or Wi-Fi), if the background noise is louder than the signal (a negative SNR), the receiver just hears static. + +LoRa is special. It uses **"Spread Spectrum"** modulation, which allows the radio to mathematically pull a signal out of the air even when it is buried deep *underneath* the background noise. This is why you will frequently see **negative SNR numbers** in Meshtastic (e.g., -10 dB, which means the signal is 10 decibels weaker than the background static). + +Depending on which Meshtastic preset you are using (e.g., `LongFast` vs. `ShortFast`), the radio has a specific **SNR Limit** — the absolute maximum amount of noise it can tolerate before the message is completely lost to the static. + +--- + +## 3. How the Signal Meter Calculates Quality + +The Meshtastic apps take both RSSI and SNR and run them through a specific formula to assign your signal a quality rating (None, Bad, Fair, or Good). It specifically scales these values based on the physical limits of the radio preset you are using. + +Here is exactly how the app decides how many bars (or what color) to show you: + +| Level | Bars | Criteria | Meaning | +|-------|------|----------|---------| +| Good | 3 | RSSI better than `-115 dBm` **AND** SNR above the baseline limit for your preset | Signal is both loud and clear — healthy connection. | +| Fair | 2 | Falls between Good and Bad | Signal getting quieter or noisier, but the radio understands the message fine. | +| Bad | 1 | RSSI drops to `-120 dBm` or worse, **OR** SNR within `5.5 dB` of your preset's absolute breaking point | Barely hanging on — at the edge of range or heavy interference. | +| None | 0 | RSSI worse than `-126 dBm` **AND** SNR has fallen `7.5 dB` below the ideal limit | Transmission completely buried in static. | + +--- + +## 4. What This Means for You + +Because Meshtastic's meter acts as a **"Clarity Meter"**, it behaves differently than what most people expect: + +> **Tip — Don't panic over low RSSI:** You might see a seemingly terrible RSSI value like `-118 dBm`. On a cell phone, you would have zero bars. But if you have an SNR of `+2 dB`, Meshtastic will still show a strong signal! *The library is quiet, so the whisper is heard perfectly.* + +> **Warning — Watch out for local noise:** If you hook up a massive antenna and see a great RSSI (e.g., `-90 dBm`) but your signal meter is only showing **1 Bar (Bad)**, you have a problem. It means you have local interference — perhaps a cheap power supply, a noisy computer, or a nearby radio tower — creating so much static that it is drowning out your mesh. + +## Where Signal Information Appears + +In the app, signal data is shown in several places: + +- **Node list** — signal bars icon next to each node +- **Node detail** — SNR, RSSI, and signal quality in the device metrics section +- **Traceroute** — per-hop signal quality for each relay node +- **Signal metrics** — historical SNR and RSSI data in the metrics charts + +![Node entry showing SNR, RSSI values and colored signal bars](/assets/screenshots/nodes_signal_info.png) + diff --git a/docs/user/tak.md b/docs/user/tak.md new file mode 100644 index 000000000..df6e8b827 --- /dev/null +++ b/docs/user/tak.md @@ -0,0 +1,124 @@ +--- +title: TAK Integration +nav_order: 10 +last_updated: 2026-05-13 +description: Interoperate with ATAK and WinTAK — CoT position sharing, TAK roles, and plugin setup. +aliases: + - tak + - atak + - team-awareness-kit +--- + +# TAK Integration + +Meshtastic integrates with the Team Awareness Kit (TAK) ecosystem, enabling interoperability between Meshtastic mesh devices and TAK applications like ATAK and WinTAK. + +## Overview + +The TAK module allows Meshtastic nodes to: +- Share position data in TAK-compatible CoT (Cursor on Target) format +- Appear as team members on TAK map displays +- Receive TAK PLI (Position Location Information) messages + +## Setup + +### Prerequisites + +- ATAK (Android Team Awareness Kit) or WinTAK installed +- Meshtastic ATAK Plugin installed +- TAK module enabled on your Meshtastic radio + +### Configuration + +1. Navigate to **Settings → Module Config → TAK**. +2. Enable the TAK module. +3. Configure the TAK team/group settings: + +![Module toggle switch](/assets/screenshots/settings_switch.png) + +| Setting | Description | +|---------|-------------| +| Enabled | Activate TAK interop | +| Mode | TAK-compatible output mode | + +### ATAK Plugin Setup + +1. Install the Meshtastic ATAK Plugin from the plugin repository. +2. Open ATAK and enable the Meshtastic plugin. +3. The plugin bridges messages between ATAK and your mesh network. + +## TAK Roles + +Nodes configured with TAK-related roles behave differently from standard clients: + +| Role | Description | +|------|-------------| +| **TAK** | Full TAK interoperability — sends and receives CoT data, chat messages, and PLI updates. Functions as a standard client plus TAK bridge. | +| **TAK Tracker** | Position-only TAK output — automatically broadcasts PLI at regular intervals without user interaction. Optimized for unattended position beacons (vehicles, equipment, waypoints). Does not relay chat messages. | + +> 💡 **Tip:** Use **TAK Tracker** for devices that only need to report position (e.g., a radio mounted in a vehicle). Use **TAK** for devices where users actively participate in TAK operations. + +### CoT (Cursor on Target) Format + +TAK messages use the Cursor on Target XML format — a military standard for sharing situational awareness data. Meshtastic converts its internal protobuf messages to CoT format when bridging to TAK systems, so no manual format conversion is needed. + +## TAK Identity + +When using TAK roles, your node broadcasts identity information that appears on TAK maps: + +| Setting | Description | +|---------|-------------| +| Team | Your team color on the TAK map (e.g., Blue, Red, Cyan, Green) | +| Role | Your operational role (Team Member, Team Lead, HQ, Medic, RTO, etc.) | +| Callsign | Your TAK callsign (defaults to your Meshtastic long name) | + +These settings appear in **Settings → Module Config → TAK** when the TAK module is enabled. + +> 💡 **Tip:** Team/role colors are the standard TAK affiliation colors. Coordinate with your TAK team to use consistent team assignments. + +## Wire Format (V1 / V2) + +Meshtastic supports two TAK wire formats: + +| Format | Compatibility | Features | +|--------|--------------|----------| +| V1 (Legacy) | ATAK Plugin v1.x, older firmware | Basic CoT position sharing only | +| V2 (Current) | ATAK Plugin v2.x, firmware 2.3+ | Full CoT support including chat, routes, zstd compression | + +The app automatically selects V2 when both sides support it. No manual configuration needed — the TAK module negotiates format based on firmware capabilities. + +## Usage with ATAK + +Once configured: +- Meshtastic nodes appear as markers on the ATAK map with callsign labels +- Chat messages can bridge between mesh and TAK networks +- Position updates flow bidirectionally between Meshtastic and TAK +- TAK Tracker nodes broadcast PLI automatically — their positions appear on ATAK maps without any ATAK-side configuration + +> ⚠️ **Note:** TAK integration requires specific node roles and module configuration. Standard client nodes don't automatically participate in TAK operations. + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| Node doesn't appear on ATAK map | TAK module disabled or wrong role | Verify TAK module is enabled and node role is TAK or TAK Tracker | +| Position updates are stale | GPS fix lost or interval too long | Check GPS status; reduce position broadcast interval in Position Config | +| ATAK plugin shows "disconnected" | BLE connection lost or plugin crashed | Reconnect Bluetooth in Meshtastic app, then restart ATAK plugin | +| Chat messages not bridging | V1 format doesn't support chat | Ensure both nodes run firmware 2.3+ for V2 wire format | +| CoT data not flowing | Channel mismatch | All TAK nodes must be on the same channel with matching encryption | + +## Security Considerations + +- TAK data shares your position and callsign information +- Ensure your channel encryption is configured when using TAK in sensitive environments +- The TAK module respects the same channel encryption as other Meshtastic messages + +## Related Topics + +- [Settings — Modules & Admin](settings-module-admin) — TAK module configuration +- [Nodes](nodes) — TAK and TAK Tracker roles in the node list +- [Map & Waypoints](map-and-waypoints) — node positions on the map +- [ATAK plugin guide](https://meshtastic.org/docs/software/integrations/atak-plugin) — detailed ATAK setup on meshtastic.org + +--- + diff --git a/docs/user/telemetry-and-sensors.md b/docs/user/telemetry-and-sensors.md new file mode 100644 index 000000000..0ce596f0f --- /dev/null +++ b/docs/user/telemetry-and-sensors.md @@ -0,0 +1,118 @@ +--- +title: Telemetry & Sensors +nav_order: 9 +last_updated: 2026-05-13 +description: Sensor data on the mesh — supported environment, air quality, and power sensors, plus configuration and viewing guides. +aliases: + - sensors + - environment + - weather + - power-metrics +--- + +# Telemetry & Sensors + +Meshtastic nodes can collect and share sensor data across the mesh network. + +## Overview + +Telemetry allows nodes equipped with sensors to broadcast environmental, power, and device health information. This data is visible on the node detail screen and can be logged over time. + +## Device Telemetry + +All Meshtastic nodes report basic device telemetry: + +| Metric | Description | Typical Range | +|--------|-------------|---------------| +| Battery Level | Charge percentage | 0–100% | +| Voltage | Battery voltage | 3.0–4.2V (LiPo) | +| Channel Utilization | % of airtime used locally | 0–100% | +| Air Utilization TX | % of airtime used by this node | 0–100% | +| Uptime | Seconds since last boot | Varies | + +## Environment Sensors + +Supported environmental sensors: + +### Temperature & Humidity + +| Sensor | Temperature | Humidity | Pressure | Notes | +|--------|-------------|----------|----------|-------| +| BME280 | ✓ | ✓ | ✓ | Recommended all-in-one | +| BME680 | ✓ | ✓ | ✓ | Adds gas resistance/IAQ | +| SHT31 | ✓ | ✓ | — | High accuracy | +| MCP9808 | ✓ | — | — | Precision temperature | +| LPS22 | — | — | ✓ | Pressure only | + +### Air Quality + +| Sensor | Metric | Notes | +|--------|--------|-------| +| BME680 | Gas Resistance / IAQ | Volatile organic compounds | +| PMSA003I | PM1.0, PM2.5, PM10 | Particulate matter | +| SEN55 | PM, NOx, VOC, Temp, Humidity | Multi-sensor | + +### Light & UV + +| Sensor | Metric | +|--------|--------| +| OPT3001 | Ambient light (lux) | +| VEML7700 | Ambient light (lux) | +| LTR390 | UV index | + +## Power Metrics + +Nodes with INA-series power sensors can report: + +| Metric | Description | +|--------|-------------| +| Bus Voltage | Supply rail voltage | +| Current | Power consumption (mA) | +| Power | Calculated power (mW) | + +Useful for monitoring solar charging or battery health on remote nodes. + +## Configuring Telemetry + +1. Navigate to **Settings → Module Config → Telemetry**. +2. Set reporting intervals: + - **Device Metrics Interval** — how often to broadcast device metrics + - **Environment Metrics Interval** — how often to broadcast sensor data +3. Enable specific sensor types as needed. + +### Recommended Intervals + +| Use Case | Device (s) | Environment (s) | +|----------|-----------|-----------------| +| Urban mesh (many nodes) | 3600 | 3600 | +| Rural mesh (few nodes) | 900 | 900 | +| Weather station | 900 | 300 | +| Battery conservation | 7200 | 7200 | + +> ⚠️ **Note:** Shorter intervals increase airtime usage and battery drain across the mesh. + +## Viewing Telemetry + +1. Navigate to **Nodes** and select a node. +2. Telemetry sections show on the detail screen: + - Device Metrics (always available) + - Environment Metrics (if sensors present) + - Power Metrics (if INA sensor present) +3. Historical graphs show trends over time. + +![Telemetry actions](/assets/screenshots/node-metrics_telemetric_actions.png) + +## Troubleshooting + +- **No environment data showing?** The remote node needs a physical sensor connected (e.g., BME280 on I2C). Device telemetry (battery, uptime) is always available, but environment metrics require hardware. +- **Stale readings?** Check the reporting interval — very long intervals (7200s+) mean data updates infrequently. Also verify the remote node is still online. +- **Sensor conflict on I2C bus?** Some sensors share I2C addresses. If you have multiple sensors on the same bus, check for address collisions in the radio's serial debug output. + +## Related Topics + +- [Node Metrics](node-metrics) — view telemetry data on the node detail screen +- [Settings — Modules & Admin](settings-module-admin) — telemetry module configuration +- [Units & Locale](units-and-locale) — temperature and pressure display units + +--- + diff --git a/docs/user/translate.md b/docs/user/translate.md new file mode 100644 index 000000000..d97278855 --- /dev/null +++ b/docs/user/translate.md @@ -0,0 +1,97 @@ +--- +title: Translate the App +parent: User Guide +nav_order: 17 +last_updated: 2026-05-13 +aliases: + - translate + - crowdin + - localization +--- + +# Translate the App + +Contributing translations helps make Meshtastic accessible to a wider audience. The app uses [Crowdin](https://crowdin.com/) to manage community translations for both the user interface and in-app documentation. + +--- + +## What Gets Translated + +| Resource | Source Location | Notes | +|---|---|---| +| UI strings | `composeResources/values/strings.xml` | Buttons, labels, messages, and all user-visible text | +| User Guide pages | `docs/user/*.md` | In-app documentation shown in Help & Documentation | +| Fastlane metadata | `fastlane/metadata/android/en-US/` | App Store listing title, description, and changelogs | + +> **Note — Developer Guide pages are English-only.** Code-focused documentation targeting contributors is not translated. + +--- + +## How to Contribute + +1. **Visit the Crowdin project.** Open the [Meshtastic Android Crowdin project](https://crowdin.com/project/meshtastic-android) and sign in or create a free account. +2. **Choose your language.** Select an existing language or request a new one by opening a [GitHub issue](https://github.com/meshtastic/Meshtastic-Android/issues/new). +3. **Translate strings.** Crowdin shows the English source on the left and your translation on the right. Translate each string and save. +4. **Review context.** Many strings include screenshots or context comments — check these to understand where the text appears in the app. +5. **Submit.** Approved translations are automatically merged into the next release. + +> **Tip — Keep translations short.** UI strings often appear in buttons, chips, or narrow columns. If a translation is significantly longer than the English original, consider abbreviating where the meaning stays clear. + +--- + +## Adding a New Language + +If your language is not yet listed on Crowdin: + +1. Open an issue on [GitHub](https://github.com/meshtastic/Meshtastic-Android/issues/new) requesting the new locale. +2. A maintainer will add the language to Crowdin and configure `crowdin.yml`. +3. Once added, you can begin translating immediately. + +--- + +## How Translations Are Organized + +The Android app uses **Compose Multiplatform resources** for all user-visible strings: + +``` +core/resources/src/commonMain/composeResources/ +├── values/ ← English (default) +│ └── strings.xml +├── values-de/ ← German +│ └── strings.xml +├── values-fr/ ← French +│ └── strings.xml +└── ... +``` + +In-app documentation follows a similar pattern under `docs/`: + +``` +docs/ +├── user/ ← English source (default) +│ ├── onboarding.md +│ └── ... +├── fr/user/ ← French translations +│ ├── onboarding.md +│ └── ... +└── ... +``` + +The app automatically selects the correct locale based on your device's **Language & Region** settings. + +--- + +## Translation Guidelines + +- **Do not translate** technical terms like "LoRa", "MQTT", "BLE", "TAK", "SNR", or "RSSI" — these are universal. +- **Keep placeholders intact.** Strings like `%1$s` or `%d` are filled in at runtime. Do not remove or reorder them unless the grammar of your language requires it. +- **Match tone.** The app uses a friendly, direct voice. Avoid overly formal language. +- **Test if possible.** Switch your device language and open the app to see how translations look in context. + +--- + +## Questions? + +If you have questions about a specific string's context or need help getting started, open a discussion on the [Meshtastic GitHub Discussions](https://github.com/meshtastic/Meshtastic-Android/discussions) page. + +Thank you for helping expand the reach of Meshtastic! diff --git a/docs/user/units-and-locale.md b/docs/user/units-and-locale.md new file mode 100644 index 000000000..5c8783b02 --- /dev/null +++ b/docs/user/units-and-locale.md @@ -0,0 +1,118 @@ +--- +title: Units, Measurement & Locale +parent: User Guide +nav_order: 16 +last_updated: 2026-05-12 +--- + +# Units, Measurement & Locale + +The Meshtastic app automatically displays temperatures, distances, speeds, and times in the units your device is configured to use — no settings to change inside the app. + +--- + +## How It Works + +Meshtastic radios always transmit data in **metric units** (meters, °C, km/h, hPa, etc.). When the app receives this data, it uses the `MetricFormatter` utility to convert and display values in whatever unit system your device's locale specifies. + +On Android, your measurement preferences are determined by your system **Language & Region** settings. On Desktop (JVM), the app uses the JVM's default `Locale`. + +> **Tip — You never need to toggle units inside the app.** Change your system measurement preferences and every screen in Meshtastic updates automatically — node details, telemetry charts, weather, altitude, and more. + +--- + +## Temperature + +Temperature values from environment sensors are transmitted as **°C** and displayed based on your device's temperature unit preference. + +![Environment metrics with temperature](/assets/screenshots/nodes_environment_metrics.png) + +| Your Setting | You See | +|---|---| +| Celsius | 22°C | +| Fahrenheit | 72°F | + +This affects all temperature displays throughout the app: node environment telemetry, soil temperature, dew point, and telemetry chart axes. + +## Distance & Altitude + +Distances between nodes and GPS altitudes are transmitted as **meters** and automatically scaled and converted. + +![Distance info display](/assets/screenshots/nodes_distance_info.png) + +| Your Setting | Small Distance | Large Distance | Altitude | +|---|---|---|---| +| Metric | 350 m | 2.5 km | 1,200 m | +| Imperial (US) | 1,148 ft | 1.6 mi | 3,937 ft | + +The app uses natural scaling — short distances stay in meters or feet, while longer distances switch to kilometres or miles automatically. + +### Where these appear + +- **Node list** — distance and bearing to each node +- **Node detail** — altitude, distance from your position +- **Map** — waypoint distances, traceroute hop distances +- **Compass** — distance to selected node + +## Speed + +GPS ground speed is displayed in your locale's preferred speed unit. + +| Your Setting | You See | +|---|---| +| Metric | 12 km/h | +| Imperial (US) | 7 mph | + +## Wind + +Wind speed and gust data from environment sensors are transmitted as **m/s** and converted for display. + +| Your Setting | You See | +|---|---| +| Metric | 5 m/s | +| Imperial (US) | 11 mph | + +Wind readings appear in the **Node Detail** environment section and the **Environment Telemetry** charts. + +## Rainfall + +Rainfall measurements (1-hour and 24-hour totals) are transmitted as **mm** and converted for display. + +| Your Setting | You See | +|---|---| +| Metric | 12 mm | +| Imperial (US) | 0.5 in | + +## Units That Never Change + +Some units are international standards and are displayed the same way regardless of your locale: + +| Measurement | Unit | Why | +|---|---|---| +| Barometric pressure | hPa | International meteorological standard | +| Heading / bearing | ° (degrees) | Universal navigation convention | +| Radiation | μR/hr | Standard dosimetry unit | +| GPS coordinates | decimal degrees | Universal geographic standard | +| Humidity, battery, soil moisture | % | Universal | + +## Date & Time + +All timestamps throughout the app — last heard, message times, telemetry logs, chart axes — follow your device's date and time preferences. + +| Setting | What It Controls | Example | +|---|---|---| +| **24-Hour Time** | Clock format | 14:30 vs 2:30 PM | +| **Date Format** | Date ordering | 09/05/2026 vs 05/09/2026 | + +The app also uses **relative time** where it makes sense — for example, "5 min ago" or "2 hours ago" in the node list — which is automatically localised into your device language. + +## Changing Your Measurement System (Android) + +On Android, your measurement system (metric vs imperial) is tied to your region setting: + +1. Open **Android Settings → System → Language & Region** +2. Change your **Region** or **Measurement units** preference +3. Return to Meshtastic — values update immediately + +> **Tip — The app uses `MetricFormatter` from `core:common`.** All measurement formatting is handled by a shared KMP utility that respects your platform's locale. Developers adding new measurement displays should use `MetricFormatter` rather than hard-coding unit conversions. + diff --git a/feature/docs/build.gradle.kts b/feature/docs/build.gradle.kts new file mode 100644 index 000000000..58406478f --- /dev/null +++ b/feature/docs/build.gradle.kts @@ -0,0 +1,149 @@ +/* + * 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 . + */ + +plugins { + alias(libs.plugins.meshtastic.kmp.feature) + alias(libs.plugins.meshtastic.kotlinx.serialization) + id("meshtastic.kmp.jvm.android") +} + +kotlin { + android { + namespace = "org.meshtastic.feature.docs" + androidResources.enable = true + } + + sourceSets { + commonMain.dependencies { + implementation(projects.core.common) + implementation(projects.core.navigation) + implementation(projects.core.resources) + implementation(projects.core.ui) + implementation(projects.core.di) + + implementation(libs.kotlinx.collections.immutable) + implementation(libs.jetbrains.navigation3.ui) + implementation(libs.jetbrains.compose.material3.adaptive.navigation3) + implementation(libs.coil) + implementation(libs.markdown.renderer) + implementation(libs.markdown.renderer.m3) + } + + commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) } + + jvmTest.dependencies { implementation(compose.desktop.currentOs) } + } +} + +/** + * Sync task: copies markdown docs from the canonical `docs/` source directory into the CMP composeResources location. + * This eliminates manual content duplication and ensures the in-app bundled docs always reflect the source-of-truth. + * + * Usage: ./gradlew :feature:docs:syncDocsToComposeResources + * + * This task runs automatically before resource generation tasks. + */ +val syncDocsToComposeResources by + tasks.registering(Sync::class) { + description = "Syncs docs/ markdown source into composeResources for in-app bundling" + group = "docs" + + val docsSourceDir = rootProject.layout.projectDirectory.dir("docs") + val screenshotsDir = rootProject.layout.projectDirectory.dir("docs/screenshots") + val composeResourcesTarget = layout.projectDirectory.dir("src/commonMain/composeResources/files/docs") + + from(docsSourceDir) { + include("user/**/*.md") + include("developer/**/*.md") + // Exclude Jekyll/site-only files that are not needed in-app + exclude("_config.yml") + exclude("_data/**") + exclude("_includes/**") + exclude("_layouts/**") + exclude("index.md") + exclude("assets/**") + exclude("Gemfile*") + } + + // FR-038: Bundle screenshots into assets/screenshots/ to match markdown image paths. + // Markdown references use `assets/screenshots/foo.png` (relative to the doc page). + // copyDocsScreenshots flattens reference PNGs into docs/screenshots/, + // so we remap them into assets/screenshots/ within the compose resource tree. + from(screenshotsDir) { + include("**/*.png") + into("assets/screenshots") + } + + into(composeResourcesTarget) + + // Preserve timestamps for up-to-date checks + preserve { include("**/.gitkeep") } + } + +// Wire sync task to run before any resource generation +tasks + .matching { + it.name.contains("generateComposeResClass") || + it.name.contains("copyNonXmlValueResources") || + it.name.contains("convertXmlValueResources") + } + .configureEach { dependsOn(syncDocsToComposeResources) } + +// FR-038: Ensure screenshots are generated before syncing docs resources +syncDocsToComposeResources.configure { dependsOn(":screenshot-tests:copyDocsScreenshots") } + +val syncTranslatedDocsToComposeResources by + tasks.registering(Copy::class) { + description = "Syncs Crowdin-translated docs into locale-qualified composeResources" + group = "docs" + + val docsDir = rootProject.layout.projectDirectory.dir("docs") + val targetBase = layout.projectDirectory.dir("src/commonMain/composeResources") + + from(docsDir) { + // Crowdin outputs dirs in Android qualifier format (fr, pt-rBR, zh-rCN) + include("*/user/**/*.md") + exclude("user/**") + exclude("developer/**") + exclude("_*/**") + exclude("assets/**") + exclude("screenshots/**") + } + + into(targetBase) + + // Crowdin %android_code% already outputs CMP qualifier format (pt-rBR), + // so we just need to prepend "files-" and nest under docs/ + eachFile { + val segments = relativePath.segments + if (segments.size >= 3) { + val qualifier = segments[0] + val rest = segments.drop(1).joinToString("/") + path = "files-$qualifier/docs/$rest" + } + } + includeEmptyDirs = false + } + +// Wire translated docs sync to resource generation alongside the primary sync +tasks + .matching { + it.name.contains("generateComposeResClass") || + it.name.contains("copyNonXmlValueResources") || + it.name.contains("convertXmlValueResources") + } + .configureEach { dependsOn(syncTranslatedDocsToComposeResources) } diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt new file mode 100644 index 000000000..6b813bf3b --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ai + +import org.meshtastic.feature.docs.model.AIDocAssistantResult + +/** + * Shared abstraction over the platform-specific docs AI assistant. + * + * Bindings: + * - Android `google` flavor: Gemini Nano implementation + * - Android `fdroid` flavor: keyword-search fallback + * - Desktop/iOS: keyword-search fallback + */ +interface AIDocAssistant { + /** Whether the AI assistant is available on the current platform/device. */ + suspend fun isSupported(): Boolean + + /** Answer a user question about Meshtastic using bundled documentation context. */ + suspend fun answer(question: String, currentPageId: String? = null): AIDocAssistantResult + + /** Reset the conversation session. Call when starting a new conversation thread. */ + fun resetSession() +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/ChirpySessionHolder.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/ChirpySessionHolder.kt new file mode 100644 index 000000000..2d24c0f5a --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/ChirpySessionHolder.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ai + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import org.koin.core.annotation.Single +import org.meshtastic.feature.docs.model.AIDocAssistantSessionState + +/** + * Global Chirpy conversation state shared across all docs panes. + * + * Registered as a Koin singleton so both the list and detail entries access the same conversation. Uses Compose + * snapshot state so reads trigger recomposition automatically. + */ +@Single +class ChirpySessionHolder { + var showSheet by mutableStateOf(false) + var sessionState by mutableStateOf(AIDocAssistantSessionState()) +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/KeywordFallbackAssistant.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/KeywordFallbackAssistant.kt new file mode 100644 index 000000000..86673f1a6 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/KeywordFallbackAssistant.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ai + +import org.koin.core.annotation.Single +import org.meshtastic.feature.docs.data.KeywordSearchEngine +import org.meshtastic.feature.docs.model.AIDocAssistantResult + +/** Keyword-search-only fallback AI assistant implementation. Used on Desktop, iOS, and Android fdroid flavor. */ +@Single(binds = []) +class KeywordFallbackAssistant(private val searchEngine: KeywordSearchEngine) : AIDocAssistant { + + override suspend fun isSupported(): Boolean = false + + override suspend fun answer(question: String, currentPageId: String?): AIDocAssistantResult { + val pages = searchEngine.selectForTokenBudget(question, maxChars = 20_000) + return if (pages.isNotEmpty()) { + AIDocAssistantResult.Fallback( + message = "AI assistant is not available on this platform. Here are pages that may help:", + suggestedPages = pages, + ) + } else { + AIDocAssistantResult.Error(reason = org.meshtastic.feature.docs.model.DocsAiError.UnsupportedPlatform) + } + } + + override fun resetSession() { + /* No-op for keyword fallback */ + } +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt new file mode 100644 index 000000000..baeb10af1 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt @@ -0,0 +1,544 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.data + +import meshtasticandroid.feature.docs.generated.resources.Res +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.currentLocaleQualifier +import org.meshtastic.core.resources.doc_keywords_connections +import org.meshtastic.core.resources.doc_keywords_desktop +import org.meshtastic.core.resources.doc_keywords_discovery +import org.meshtastic.core.resources.doc_keywords_firmware +import org.meshtastic.core.resources.doc_keywords_map +import org.meshtastic.core.resources.doc_keywords_messages +import org.meshtastic.core.resources.doc_keywords_mqtt +import org.meshtastic.core.resources.doc_keywords_node_metrics +import org.meshtastic.core.resources.doc_keywords_nodes +import org.meshtastic.core.resources.doc_keywords_onboarding +import org.meshtastic.core.resources.doc_keywords_settings_module +import org.meshtastic.core.resources.doc_keywords_settings_radio +import org.meshtastic.core.resources.doc_keywords_signal_meter +import org.meshtastic.core.resources.doc_keywords_tak +import org.meshtastic.core.resources.doc_keywords_telemetry +import org.meshtastic.core.resources.doc_keywords_translate +import org.meshtastic.core.resources.doc_keywords_units +import org.meshtastic.core.resources.doc_title_connections +import org.meshtastic.core.resources.doc_title_desktop +import org.meshtastic.core.resources.doc_title_discovery +import org.meshtastic.core.resources.doc_title_firmware +import org.meshtastic.core.resources.doc_title_map +import org.meshtastic.core.resources.doc_title_messages +import org.meshtastic.core.resources.doc_title_mqtt +import org.meshtastic.core.resources.doc_title_node_metrics +import org.meshtastic.core.resources.doc_title_nodes +import org.meshtastic.core.resources.doc_title_onboarding +import org.meshtastic.core.resources.doc_title_settings_module +import org.meshtastic.core.resources.doc_title_settings_radio +import org.meshtastic.core.resources.doc_title_signal_meter +import org.meshtastic.core.resources.doc_title_tak +import org.meshtastic.core.resources.doc_title_telemetry +import org.meshtastic.core.resources.doc_title_translate +import org.meshtastic.core.resources.doc_title_units +import org.meshtastic.feature.docs.model.DocBundle +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocPageContent +import org.meshtastic.feature.docs.model.DocSection +import org.meshtastic.feature.docs.model.KeywordIndexEntry +import org.meshtastic.core.resources.Res as CoreRes + +/** Interface for loading the packaged docs bundle. */ +interface DocBundleLoader { + suspend fun load(): DocBundle + + suspend fun readPage(pageId: String): DocPageContent? + + /** Check whether a Crowdin-translated resource exists for the given page and locale. */ + suspend fun hasTranslatedResource(pageId: String, locale: String): Boolean + + fun pagesBySection(section: DocSection): List +} + +/** + * Default implementation that loads docs from bundled markdown files packaged as Compose Resources under `files/docs/`. + * + * User guide titles and keywords are resolved from string resources so Crowdin translations propagate to the in-app + * index and search. Developer guide entries stay hardcoded English (code-focused audience). + * + * No cross-locale caching — the bundle rebuilds on each [load] call (~1 ms) so locale changes take effect immediately. + */ +@Single(binds = [DocBundleLoader::class]) +@Suppress("TooManyFunctions") +class DefaultDocBundleLoader : DocBundleLoader { + + private var lastPages: List = emptyList() + + companion object { + private const val FRONTMATTER_DELIMITER = "---" + } + + override suspend fun load(): DocBundle { + val entries = buildUserGuideIndex() + buildDeveloperGuideIndex() + val pages = entries.map { it.toDocPage() } + lastPages = pages + return DocBundle( + pages = pages, + pageIndex = pages.associateBy { it.id }, + bundleVersion = "beta", + generatedAt = "2026-05-07T00:00:00Z", + totalBytes = 0L, + ) + } + + override suspend fun readPage(pageId: String): DocPageContent? { + val bundle = load() + val page = bundle.pageIndex[pageId] ?: return null + + val markdown = loadMarkdownContent(page) + return DocPageContent(page = page, markdown = markdown, cssPath = null) + } + + /** + * Load page content with locale awareness. Tries locale-qualified Crowdin resource first, falls back to English. + * Returns a pair of (content, wasLocalized) for cascade decision. + */ + @Suppress("ReturnCount") + suspend fun readPageLocalized(pageId: String, locale: String): Pair { + val bundle = load() + val page = bundle.pageIndex[pageId] ?: return null to false + + if (locale != "en") { + val localizedMarkdown = loadLocalizedMarkdownContent(page, locale) + if (localizedMarkdown != null) { + return DocPageContent(page = page, markdown = localizedMarkdown, cssPath = null) to true + } + } + + val markdown = loadMarkdownContent(page) + return DocPageContent(page = page, markdown = markdown, cssPath = null) to false + } + + private suspend fun loadLocalizedMarkdownContent(page: DocPage, locale: String): String? { + val section = + when (page.section) { + DocSection.UserGuide -> "user" + DocSection.DeveloperGuide -> "developer" + } + // Try qualifiers in specificity order (mirrors Android resource resolution): + // "pt-rBR" → "pt" → give up + for (qualifier in localeQualifiers(locale)) { + val localePath = "files-$qualifier/docs/$section/${page.id}.md" + try { + val bytes = Res.readBytes(localePath) + return stripFrontmatter(bytes.decodeToString()) + } catch (_: Exception) { + continue + } + } + return null + } + + override suspend fun hasTranslatedResource(pageId: String, locale: String): Boolean { + val bundle = load() + val page = bundle.pageIndex[pageId] ?: return false + val section = + when (page.section) { + DocSection.UserGuide -> "user" + DocSection.DeveloperGuide -> "developer" + } + return localeQualifiers(locale).any { qualifier -> + val localePath = "files-$qualifier/docs/$section/${page.id}.md" + try { + Res.readBytes(localePath) + true + } catch (_: Exception) { + false + } + } + } + + /** + * Produces CMP resource qualifier candidates in specificity order. Tries region-qualified first (e.g. "pt-rBR"), + * then language-only ("pt"). Deduplicates when device has no region (both would be "fr"). + */ + private fun localeQualifiers(language: String): List { + val fullQualifier = currentLocaleQualifier() + return buildList { + if (fullQualifier != language) add(fullQualifier) + add(language) + } + } + + private suspend fun loadMarkdownContent(page: DocPage): String { + val section = + when (page.section) { + DocSection.UserGuide -> "user" + DocSection.DeveloperGuide -> "developer" + } + val resourcePath = "files/docs/$section/${page.id}.md" + + return try { + val bytes = Res.readBytes(resourcePath) + val raw = bytes.decodeToString() + stripFrontmatter(raw) + } catch (_: Exception) { + "# ${page.title}\n\nContent not available. The documentation file could not be loaded." + } + } + + @Suppress("ReturnCount") + private fun stripFrontmatter(content: String): String { + if (!content.startsWith(FRONTMATTER_DELIMITER)) return content + val endIndex = content.indexOf(FRONTMATTER_DELIMITER, startIndex = FRONTMATTER_DELIMITER.length) + if (endIndex < 0) return content + return content.substring(endIndex + FRONTMATTER_DELIMITER.length).trimStart() + } + + override fun pagesBySection(section: DocSection): List = + lastPages.filter { it.section == section }.sortedWith(compareBy({ it.navOrder }, { it.title })) + + /** Parse a comma-separated keyword string resource value into a list. */ + private fun parseKeywords(csv: String): List = csv.split(",").map { it.trim() }.filter { it.isNotEmpty() } + + // --------------------------------------------------------------------------- + // User Guide index — titles and keywords from string resources (translatable) + // --------------------------------------------------------------------------- + + /** + * Metadata for a user guide page. [titleRes] and [keywordsRes] are resolved at runtime from the device locale, + * allowing Crowdin translations to appear in the in-app TOC and search index. + */ + private data class UserPageDef( + val id: String, + val titleRes: StringResource, + val keywordsRes: StringResource, + val resourcePath: String, + val navOrder: Int, + val aliases: List, + val charCount: Int, + val iconId: String, + ) + + @Suppress("MagicNumber") + private val userPages = + listOf( + UserPageDef( + "onboarding", + CoreRes.string.doc_title_onboarding, + CoreRes.string.doc_keywords_onboarding, + "docs/user/onboarding.html", + 1, + listOf("first-launch", "setup", "intro"), + 3200, + "onboarding", + ), + UserPageDef( + "connections", + CoreRes.string.doc_title_connections, + CoreRes.string.doc_keywords_connections, + "docs/user/connections.html", + 2, + listOf("bluetooth", "usb", "tcp", "pairing"), + 4100, + "connections", + ), + UserPageDef( + "messages-and-channels", + CoreRes.string.doc_title_messages, + CoreRes.string.doc_keywords_messages, + "docs/user/messages-and-channels.html", + 3, + listOf("channels", "direct-messages", "messaging", "conversations"), + 4500, + "messages", + ), + UserPageDef( + "nodes", + CoreRes.string.doc_title_nodes, + CoreRes.string.doc_keywords_nodes, + "docs/user/nodes.html", + 4, + listOf("node-list", "mesh-nodes", "peers"), + 3800, + "nodes", + ), + UserPageDef( + "node-metrics", + CoreRes.string.doc_title_node_metrics, + CoreRes.string.doc_keywords_node_metrics, + "docs/user/node-metrics.html", + 5, + listOf("metrics", "telemetry", "device-metrics", "signal"), + 5200, + "node-metrics", + ), + UserPageDef( + "map-and-waypoints", + CoreRes.string.doc_title_map, + CoreRes.string.doc_keywords_map, + "docs/user/map-and-waypoints.html", + 6, + listOf("map", "waypoints", "gps", "location"), + 3600, + "map", + ), + UserPageDef( + "settings-radio-user", + CoreRes.string.doc_title_settings_radio, + CoreRes.string.doc_keywords_settings_radio, + "docs/user/settings-radio-user.html", + 7, + listOf("settings", "radio-config", "user-config", "lora"), + 6800, + "settings-radio", + ), + UserPageDef( + "settings-module-admin", + CoreRes.string.doc_title_settings_module, + CoreRes.string.doc_keywords_settings_module, + "docs/user/settings-module-admin.html", + 8, + listOf("modules", "module-config", "administration"), + 5500, + "settings-module", + ), + UserPageDef( + "telemetry-and-sensors", + CoreRes.string.doc_title_telemetry, + CoreRes.string.doc_keywords_telemetry, + "docs/user/telemetry-and-sensors.html", + 9, + listOf("sensors", "environment", "weather", "power-metrics"), + 4800, + "telemetry", + ), + UserPageDef( + "tak", + CoreRes.string.doc_title_tak, + CoreRes.string.doc_keywords_tak, + "docs/user/tak.html", + 10, + listOf("tak", "atak", "team-awareness-kit"), + 2400, + "tak", + ), + UserPageDef( + "mqtt", + CoreRes.string.doc_title_mqtt, + CoreRes.string.doc_keywords_mqtt, + "docs/user/mqtt.html", + 11, + listOf("mqtt", "internet-bridge", "broker"), + 4200, + "mqtt", + ), + UserPageDef( + "discovery", + CoreRes.string.doc_title_discovery, + CoreRes.string.doc_keywords_discovery, + "docs/user/discovery.html", + 12, + listOf("mesh-discovery", "local-discovery", "network-scan"), + 2800, + "discovery", + ), + UserPageDef( + "firmware", + CoreRes.string.doc_title_firmware, + CoreRes.string.doc_keywords_firmware, + "docs/user/firmware.html", + 13, + listOf("firmware", "update", "ota", "flash"), + 3400, + "firmware", + ), + UserPageDef( + "desktop", + CoreRes.string.doc_title_desktop, + CoreRes.string.doc_keywords_desktop, + "docs/user/desktop.html", + 14, + listOf("desktop", "linux", "macos", "windows", "jvm"), + 3900, + "desktop", + ), + UserPageDef( + "signal-meter", + CoreRes.string.doc_title_signal_meter, + CoreRes.string.doc_keywords_signal_meter, + "docs/user/signal-meter.html", + 15, + listOf("signal-quality", "signal-strength", "rssi", "snr"), + 3500, + "signal-meter", + ), + UserPageDef( + "units-and-locale", + CoreRes.string.doc_title_units, + CoreRes.string.doc_keywords_units, + "docs/user/units-and-locale.html", + 16, + listOf("measurement", "units", "locale", "metric", "imperial"), + 3800, + "units-locale", + ), + UserPageDef( + "translate", + CoreRes.string.doc_title_translate, + CoreRes.string.doc_keywords_translate, + "docs/user/translate.html", + 17, + listOf("crowdin", "localization", "language", "i18n", "contribute"), + 3700, + "translate", + ), + ) + + private suspend fun buildUserGuideIndex(): List = userPages.map { def -> + KeywordIndexEntry( + id = def.id, + title = getString(def.titleRes), + section = "user", + resourcePath = def.resourcePath, + navOrder = def.navOrder, + keywords = parseKeywords(getString(def.keywordsRes)), + aliases = def.aliases, + charCount = def.charCount, + iconId = def.iconId, + ) + } + + // --------------------------------------------------------------------------- + // Developer Guide index — hardcoded English (code-focused, not translated) + // --------------------------------------------------------------------------- + + @Suppress("LongMethod", "MagicNumber") + private fun buildDeveloperGuideIndex(): List = listOf( + KeywordIndexEntry( + "architecture", + "Architecture", + "developer", + "docs/developer/architecture.html", + 1, + listOf("architecture", "kmp", "module", "layer", "core", "feature", "compose"), + listOf("layers", "module-architecture", "kmp"), + 4600, + "architecture", + ), + KeywordIndexEntry( + "codebase", + "Codebase", + "developer", + "docs/developer/codebase.html", + 2, + listOf("codebase", "repository", "layout", "gradle", "build", "namespace", "convention"), + listOf("repository-layout", "project-structure", "source-code"), + 3700, + "codebase", + ), + KeywordIndexEntry( + "adding-a-feature-module", + "Adding a Feature Module", + "developer", + "docs/developer/adding-a-feature-module.html", + 3, + listOf("module", "feature", "new", "create", "plugin", "di", "koin"), + listOf("new-module", "feature-module", "module-guide"), + 3200, + "adding-features", + ), + KeywordIndexEntry( + "navigation-and-deep-links", + "Navigation & Deep Links", + "developer", + "docs/developer/navigation-and-deep-links.html", + 4, + listOf("navigation", "deeplink", "route", "navkey", "backstack", "typed"), + listOf("deeplinks", "navigation-3", "routes"), + 4100, + "navigation", + ), + KeywordIndexEntry( + "transport", + "Transport", + "developer", + "docs/developer/transport.html", + 5, + listOf("transport", "ble", "serial", "tcp", "radio", "connection"), + listOf("ble", "serial", "tcp", "radio-transport"), + 3000, + "transport", + ), + KeywordIndexEntry( + "persistence", + "Persistence", + "developer", + "docs/developer/persistence.html", + 6, + listOf("room", "database", "datastore", "prefs", "storage", "migration"), + listOf("room", "database", "datastore", "prefs"), + 2800, + "persistence", + ), + KeywordIndexEntry( + "testing", + "Testing", + "developer", + "docs/developer/testing.html", + 7, + listOf("test", "unit", "screenshot", "compose", "roborazzi", "ci"), + listOf("tests", "unit-tests", "screenshot-tests"), + 3100, + "testing", + ), + KeywordIndexEntry( + "contributing", + "Contributing", + "developer", + "docs/developer/contributing.html", + 8, + listOf("contributing", "pull-request", "branch", "commit", "style", "pr"), + listOf("contributing", "pull-request", "branch-naming"), + 2900, + "contributing", + ), + KeywordIndexEntry( + "measurement", + "Measurement & Formatting", + "developer", + "docs/developer/measurement.html", + 9, + listOf("formatter", "metric", "number", "locale", "temperature", "conversion", "api"), + listOf("metric-formatter", "number-formatter", "measurement"), + 5400, + "measurement", + ), + ) + + private fun KeywordIndexEntry.toDocPage(): DocPage = DocPage( + id = id, + title = title, + section = DocSection.fromString(section), + navOrder = navOrder, + resourcePath = resourcePath, + keywords = keywords, + aliases = aliases, + charCount = charCount, + iconId = iconId, + ) +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt new file mode 100644 index 000000000..c9d9ca90e --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.data + +import org.koin.core.annotation.Single +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocSearchQuery +import org.meshtastic.feature.docs.model.DocSearchResult + +/** + * Keyword-based search engine for the docs corpus. Provides search functionality without AI, working on all platforms. + */ +@Single +class KeywordSearchEngine(private val bundleLoader: DocBundleLoader) { + private val stopWords = + setOf( + "the", + "a", + "an", + "is", + "are", + "was", + "were", + "be", + "been", + "to", + "of", + "in", + "for", + "on", + "with", + "at", + "by", + "from", + "it", + "this", + "that", + "how", + "what", + "where", + "when", + "do", + "does", + "can", + "will", + "my", + "i", + "me", + "and", + "or", + ) + + /** Search the docs corpus with the given query text. */ + @Suppress("ReturnCount") + suspend fun search(queryText: String): List { + if (queryText.isBlank()) return emptyList() + + val query = normalize(queryText) + if (query.normalizedTerms.isEmpty()) return emptyList() + + val bundle = bundleLoader.load() + return bundle.pages + .map { page -> score(page, query) } + .filter { it.score > 0 } + .sortedWith(compareByDescending { it.score }.thenBy { it.page.navOrder }) + } + + /** Select top pages within a token budget for AI retrieval context. */ + suspend fun selectForTokenBudget(queryText: String, maxChars: Int): List { + val results = search(queryText) + val selected = mutableListOf() + var totalChars = 0 + + for (result in results) { + if (totalChars + result.page.charCount > maxChars) break + selected.add(result.page) + totalChars += result.page.charCount + } + + return selected + } + + fun normalize(queryText: String): DocSearchQuery { + val terms = + queryText + .lowercase() + .replace(Regex("[^\\p{L}\\p{N}\\s-]"), " ") + .split(Regex("\\s+")) + .filter { it.length >= 2 && it !in stopWords } + .distinct() + + return DocSearchQuery(rawText = queryText, normalizedTerms = terms) + } + + @Suppress("LoopWithTooManyJumpStatements") + private fun score(page: DocPage, query: DocSearchQuery): DocSearchResult { + var totalScore = 0 + val matchedTerms = mutableListOf() + + for (term in query.normalizedTerms) { + // Title match (highest priority) + if (page.title.lowercase().contains(term)) { + totalScore += TITLE_MATCH_SCORE + matchedTerms.add(term) + continue + } + + // Exact keyword match + if (page.keywords.any { it.lowercase() == term }) { + totalScore += KEYWORD_EXACT_SCORE + matchedTerms.add(term) + continue + } + + // Partial keyword match + if (page.keywords.any { it.lowercase().contains(term) }) { + totalScore += KEYWORD_PARTIAL_SCORE + matchedTerms.add(term) + continue + } + + // Alias match + if (page.aliases.any { it.lowercase() == term || it.lowercase().contains(term) }) { + totalScore += ALIAS_MATCH_SCORE + matchedTerms.add(term) + continue + } + + // ID/slug match + if (page.id.contains(term)) { + totalScore += ID_MATCH_SCORE + matchedTerms.add(term) + } + } + + return DocSearchResult(page = page, score = totalScore, matchedTerms = matchedTerms) + } + + companion object { + const val TITLE_MATCH_SCORE = 10 + const val KEYWORD_EXACT_SCORE = 7 + const val KEYWORD_PARTIAL_SCORE = 4 + const val ALIAS_MATCH_SCORE = 5 + const val ID_MATCH_SCORE = 3 + } +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt new file mode 100644 index 000000000..84f05c1a6 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Module + +@Module +@ComponentScan("org.meshtastic.feature.docs") +class FeatureDocsModule diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt new file mode 100644 index 000000000..7fc4bbfce --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.model + +import kotlinx.serialization.Serializable +import org.jetbrains.compose.resources.getString +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.doc_section_developer +import org.meshtastic.core.resources.doc_section_user + +/** Top-level documentation section. */ +@Serializable +sealed interface DocSection { + @Serializable data object UserGuide : DocSection + + @Serializable data object DeveloperGuide : DocSection + + companion object { + fun fromString(value: String): DocSection = when (value.lowercase()) { + "user" -> UserGuide + "developer" -> DeveloperGuide + else -> UserGuide + } + + fun toSlug(section: DocSection): String = when (section) { + UserGuide -> "user" + DeveloperGuide -> "developer" + } + + suspend fun displayName(section: DocSection): String = when (section) { + UserGuide -> getString(Res.string.doc_section_user) + DeveloperGuide -> getString(Res.string.doc_section_developer) + } + } +} + +/** A single documentation page. */ +@Serializable +data class DocPage( + val id: String, + val title: String, + val section: DocSection, + val navOrder: Int, + val resourcePath: String, + val keywords: List, + val aliases: List = emptyList(), + val charCount: Int, + /** Icon identifier for TOC display (maps to MeshtasticIcons). */ + val iconId: String? = null, +) + +/** Content wrapper that decouples metadata from rendered content. */ +data class DocPageContent( + val page: DocPage, + val html: String? = null, + val markdown: String? = null, + val cssPath: String? = null, +) + +/** Runtime aggregate of the full documentation corpus. */ +data class DocBundle( + val pages: List, + val pageIndex: Map, + val bundleVersion: String, + val generatedAt: String, + val totalBytes: Long, +) + +/** Build-time keyword index entry decoded at runtime. */ +@Serializable +data class KeywordIndexEntry( + val id: String, + val title: String, + val section: String, + val resourcePath: String, + val navOrder: Int, + val keywords: List, + val aliases: List = emptyList(), + val charCount: Int, + val iconId: String? = null, +) + +/** Normalized user search query. */ +data class DocSearchQuery(val rawText: String, val normalizedTerms: List) + +/** Ranked search result. */ +data class DocSearchResult(val page: DocPage, val score: Int, val matchedTerms: List) + +/** AI assistant result model. */ +sealed interface AIDocAssistantResult { + data class Success(val answer: String, val sourcePages: List, val usedOnDeviceModel: Boolean) : + AIDocAssistantResult + + data class Fallback(val message: String, val suggestedPages: List) : AIDocAssistantResult + + data class Error(val reason: DocsAiError, val suggestedPages: List = emptyList()) : AIDocAssistantResult +} + +/** AI error categories. */ +sealed interface DocsAiError { + data object UnsupportedPlatform : DocsAiError + + data object UnsupportedFlavor : DocsAiError + + data object ModelUnavailable : DocsAiError + + data object Busy : DocsAiError + + data object TokenBudgetExceeded : DocsAiError + + data object Unknown : DocsAiError +} + +/** Chirpy assistant session state. */ +data class AIDocAssistantSessionState( + val messages: List = emptyList(), + val isLoading: Boolean = false, + val draftQuestion: String = "", +) + +/** Reference to a source doc page shown as a chip in Chirpy replies. */ +@Serializable data class SourceRef(val id: String, val title: String) + +/** A single message in the Chirpy conversation. */ +@Serializable +data class ChirpyMessage( + val id: String, + val role: ChirpyRole, + val text: String, + val sources: List = emptyList(), +) + +/** Message author role. */ +@Serializable +enum class ChirpyRole { + USER, + ASSISTANT, + SYSTEM, +} + +/** Indicates the source of displayed page content for translation attribution. */ +enum class TranslationSource { + /** English source or Crowdin community translation. */ + BUNDLED, + + /** ML Kit on-device auto-translation (Google flavor only). */ + ML_KIT, +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt new file mode 100644 index 000000000..b1d93be72 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt @@ -0,0 +1,329 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.navigation + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavKey +import androidx.navigationevent.NavigationEventInfo +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koin.compose.koinInject +import org.meshtastic.core.common.util.currentLocaleCode +import org.meshtastic.core.common.util.ioDispatcher +import org.meshtastic.core.navigation.SettingsRoute +import org.meshtastic.feature.docs.ai.AIDocAssistant +import org.meshtastic.feature.docs.ai.ChirpySessionHolder +import org.meshtastic.feature.docs.data.DefaultDocBundleLoader +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.ChirpyMessage +import org.meshtastic.feature.docs.model.ChirpyRole +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocPageContent +import org.meshtastic.feature.docs.model.SourceRef +import org.meshtastic.feature.docs.model.TranslationSource +import org.meshtastic.feature.docs.translation.DocTranslationService +import org.meshtastic.feature.docs.translation.TranslationResult +import org.meshtastic.feature.docs.ui.DocsBrowserScreen +import org.meshtastic.feature.docs.ui.DocsPageRouteScreen +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +/** Registers docs navigation entries into the Settings navigation graph. */ +@OptIn(ExperimentalUuidApi::class, ExperimentalMaterial3AdaptiveApi::class) +fun EntryProviderScope.docsEntries(backStack: NavBackStack) { + entry(metadata = { ListDetailSceneStrategy.listPane() }) { + val hasDetailSelected = remember(backStack) { backStack.any { it is SettingsRoute.HelpDocPage } } + val chirpy = rememberChirpyState(backStack = backStack, currentPageId = null, showFab = !hasDetailSelected) + DocsHelpScreen(backStack = backStack, chirpy = chirpy) + } + + entry(metadata = { ListDetailSceneStrategy.detailPane() }) { route -> + val chirpy = rememberChirpyState(backStack = backStack, currentPageId = route.pageId, showFab = true) + DocsPageScreen(pageId = route.pageId, backStack = backStack, chirpy = chirpy) + } +} + +// ── Shared Chirpy state holder ────────────────────────────────────────────────── + +/** All Chirpy UI state needed by screen composables. */ +class ChirpyUiState( + val isSupported: Boolean, + val showFab: Boolean, + val showSheet: Boolean, + val sessionState: org.meshtastic.feature.docs.model.AIDocAssistantSessionState, + val onToggle: () -> Unit, + val onDismiss: () -> Unit, + val onDraftChange: (String) -> Unit, + val onSubmit: () -> Unit, + val onNavigateToPage: (String) -> Unit, +) + +@OptIn(ExperimentalUuidApi::class) +@Composable +private fun rememberChirpyState( + backStack: NavBackStack, + currentPageId: String?, + showFab: Boolean, +): ChirpyUiState { + val aiAssistant = koinInject() + val holder = koinInject() + val scope = rememberCoroutineScope() + + var isSupported by remember { mutableStateOf(false) } + + // Poll for AI availability. + LaunchedEffect(Unit) { + repeat(AI_SUPPORT_CHECK_RETRIES) { + isSupported = aiAssistant.isSupported() + if (isSupported) return@LaunchedEffect + kotlinx.coroutines.delay(AI_SUPPORT_CHECK_INTERVAL_MS) + } + } + + // Auto-introduce Chirpy when the sheet first opens. + LaunchedEffect(holder.showSheet) { + if (holder.showSheet && holder.sessionState.messages.isEmpty() && !holder.sessionState.isLoading) { + aiAssistant.resetSession() + holder.sessionState = holder.sessionState.copy(isLoading = true) + val result = aiAssistant.answer(CHIRPY_INTRO_PROMPT, currentPageId = currentPageId) + val introMsg = chirpyResultToMessage(result) + holder.sessionState = + holder.sessionState.copy(messages = holder.sessionState.messages + introMsg, isLoading = false) + } + } + + fun submit() { + val question = holder.sessionState.draftQuestion.trim() + if (question.isNotBlank() && !holder.sessionState.isLoading) { + val userMsg = ChirpyMessage(id = Uuid.random().toString(), role = ChirpyRole.USER, text = question) + holder.sessionState = + holder.sessionState.copy( + messages = holder.sessionState.messages + userMsg, + draftQuestion = "", + isLoading = true, + ) + scope.launch { + val result = aiAssistant.answer(question, currentPageId = currentPageId) + val responseMsg = chirpyResultToMessage(result) + holder.sessionState = + holder.sessionState.copy(messages = holder.sessionState.messages + responseMsg, isLoading = false) + } + } + } + + return ChirpyUiState( + isSupported = isSupported, + showFab = showFab, + showSheet = holder.showSheet, + sessionState = holder.sessionState, + onToggle = { holder.showSheet = !holder.showSheet }, + onDismiss = { holder.showSheet = false }, + onDraftChange = { holder.sessionState = holder.sessionState.copy(draftQuestion = it) }, + onSubmit = ::submit, + onNavigateToPage = { pageId -> + holder.showSheet = false + backStack.add(SettingsRoute.HelpDocPage(pageId)) + }, + ) +} + +// ── Screen composables ────────────────────────────────────────────────────────── + +@Composable +private fun DocsHelpScreen(backStack: NavBackStack, chirpy: ChirpyUiState) { + val bundleLoader = koinInject() + val searchEngine = koinInject() + + var pages by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var searchQuery by remember { mutableStateOf("") } + + LaunchedEffect(Unit) { + val bundle = bundleLoader.load() + pages = bundle.pages.sortedWith(compareBy({ it.section.toString() }, { it.navOrder })) + isLoading = false + } + + LaunchedEffect(searchQuery) { + if (searchQuery.isBlank()) { + val bundle = bundleLoader.load() + pages = bundle.pages.sortedWith(compareBy({ it.section.toString() }, { it.navOrder })) + } else { + val results = searchEngine.search(searchQuery) + pages = results.map { it.page } + } + } + + val backHandlerState = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler(state = backHandlerState, onBackCompleted = { backStack.removeLastOrNull() }) + + DocsBrowserScreen( + pages = pages, + isLoading = isLoading, + searchQuery = searchQuery, + onSearchQueryChange = { searchQuery = it }, + onSelectPage = { pageId -> backStack.add(SettingsRoute.HelpDocPage(pageId)) }, + onBack = { backStack.removeLastOrNull() }, + isAiSupported = chirpy.isSupported, + showFab = chirpy.showFab, + showChirpy = chirpy.showSheet, + chirpyState = chirpy.sessionState, + onChirpyToggle = chirpy.onToggle, + onChirpyDismiss = chirpy.onDismiss, + onChirpyDraftChange = chirpy.onDraftChange, + onChirpySubmit = chirpy.onSubmit, + onChirpyNavigateToPage = chirpy.onNavigateToPage, + ) +} + +@Suppress("LongMethod") +@Composable +private fun DocsPageScreen(pageId: String, backStack: NavBackStack, chirpy: ChirpyUiState) { + val bundleLoader = koinInject() + val translationService = koinInject() + + var content by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(true) } + var translationSource by remember { mutableStateOf(TranslationSource.BUNDLED) } + + val locale = currentLocaleCode() + + LaunchedEffect(pageId, locale) { + isLoading = true + val loader = bundleLoader as? DefaultDocBundleLoader + + // Try locale-aware loading: Crowdin bundle first, then English fallback + val (loaded, wasCrowdinLocalized) = + if (loader != null) { + withContext(ioDispatcher) { loader.readPageLocalized(pageId, locale) } + } else { + withContext(ioDispatcher) { bundleLoader.readPage(pageId) } to false + } + + when { + // Crowdin provided a localized version — use it directly + wasCrowdinLocalized && loaded != null -> { + content = loaded + translationSource = TranslationSource.BUNDLED + isLoading = false + } + + // Non-English with no Crowdin — attempt ML Kit runtime translation + locale != "en" && loaded != null -> { + // Show English content immediately while translation runs + content = loaded + translationSource = TranslationSource.BUNDLED + isLoading = false + + val result = + withContext(ioDispatcher) { + translationService.translatePage(pageId, loaded.markdown ?: "", locale) + } + when (result) { + is TranslationResult.Success -> { + content = loaded.copy(markdown = result.translatedMarkdown) + translationSource = TranslationSource.ML_KIT + } + + else -> { + /* Keep English content already displayed */ + } + } + } + + // English locale or load failure + else -> { + content = loaded + translationSource = TranslationSource.BUNDLED + isLoading = false + } + } + } + + val backHandlerState = rememberNavigationEventState(NavigationEventInfo.None) + NavigationBackHandler(state = backHandlerState, onBackCompleted = { backStack.removeLastOrNull() }) + + DocsPageRouteScreen( + pageId = pageId, + content = content, + isLoading = isLoading, + translationSource = translationSource, + isNonEnglish = locale != "en", + isAiSupported = chirpy.isSupported, + showChirpy = chirpy.showSheet, + chirpyState = chirpy.sessionState, + onChirpyToggle = chirpy.onToggle, + onChirpyDismiss = chirpy.onDismiss, + onChirpyDraftChange = chirpy.onDraftChange, + onChirpySubmit = chirpy.onSubmit, + onChirpyNavigateToPage = chirpy.onNavigateToPage, + onBack = { backStack.removeLastOrNull() }, + onNavigateToPage = { targetPageId -> backStack.add(SettingsRoute.HelpDocPage(targetPageId)) }, + ) +} + +// ── Constants & helpers ───────────────────────────────────────────────────────── + +/** How often to re-check AI model availability while waiting for download. */ +private const val AI_SUPPORT_CHECK_INTERVAL_MS = 3_000L + +/** Maximum number of AI support checks before giving up. */ +private const val AI_SUPPORT_CHECK_RETRIES = 15 + +/** Prompt sent automatically when the Chirpy sheet opens to generate a natural introduction. */ +private const val CHIRPY_INTRO_PROMPT = "Introduce yourself briefly. Who are you and what can you help with?" + +/** Maps an [AIDocAssistantResult] to a [ChirpyMessage]. */ +@OptIn(ExperimentalUuidApi::class) +private fun chirpyResultToMessage(result: AIDocAssistantResult): ChirpyMessage = when (result) { + is AIDocAssistantResult.Success -> + ChirpyMessage( + id = Uuid.random().toString(), + role = ChirpyRole.ASSISTANT, + text = result.answer, + sources = result.sourcePages.map { SourceRef(id = it.id, title = it.title) }, + ) + + is AIDocAssistantResult.Fallback -> + ChirpyMessage( + id = Uuid.random().toString(), + role = ChirpyRole.ASSISTANT, + text = result.message, + sources = result.suggestedPages.map { SourceRef(id = it.id, title = it.title) }, + ) + + is AIDocAssistantResult.Error -> + ChirpyMessage( + id = Uuid.random().toString(), + role = ChirpyRole.SYSTEM, + text = "Sorry, I couldn't answer that. ${result.reason}", + ) +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCache.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCache.kt new file mode 100644 index 000000000..690a03810 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCache.kt @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okio.FileSystem +import okio.IOException +import okio.Path +import okio.Path.Companion.toPath +import okio.buffer + +/** + * File-based cache for ML Kit translated markdown pages. + * + * Cache key: `{pageId}#{locale}#{md5(sourceContent)}` When the English source changes, the md5 changes and the old + * cache entry becomes stale. Eviction by oldest-access at [maxCacheSizeBytes] (default 50MB). + */ +class DocTranslationCache( + private val cacheDir: Path, + private val fileSystem: FileSystem = FileSystem.SYSTEM, + private val maxCacheSizeBytes: Long = MAX_CACHE_SIZE_BYTES, +) { + companion object { + const val MAX_CACHE_SIZE_BYTES = 50L * 1024 * 1024 // 50 MB + private const val CACHE_SUBDIR = "docs_translation_cache" + private const val EVICTION_TARGET_PERCENT = 75 + private const val PERCENT_DIVISOR = 100 + private const val TAG = "DocTranslationCache" + } + + private val mutex = Mutex() + + private val cacheRoot: Path + get() = cacheDir / CACHE_SUBDIR + + /** Get a cached translation, or null if not cached or stale. */ + suspend fun get(pageId: String, locale: String, sourceHash: String): String? = mutex.withLock { + val file = cacheFile(pageId, locale, sourceHash) + try { + if (fileSystem.exists(file)) { + val bufferedSource = fileSystem.source(file).buffer() + val content = bufferedSource.readUtf8() + bufferedSource.close() + // Touch file to update access time for eviction ordering + touchFile(file) + content + } else { + null + } + } catch (e: IOException) { + Logger.w(tag = TAG) { "Cache read failed for $pageId/$locale: ${e.message}" } + null + } + } + + /** Store a translated page in the cache. Evicts old entries if over size limit. */ + suspend fun put(pageId: String, locale: String, sourceHash: String, translatedMarkdown: String) = mutex.withLock { + try { + fileSystem.createDirectories(cacheRoot) + val file = cacheFile(pageId, locale, sourceHash) + // Write to temp file then move for atomicity + val tmpFile = cacheRoot / "${file.name}.tmp" + val bufferedSink = fileSystem.sink(tmpFile).buffer() + bufferedSink.writeUtf8(translatedMarkdown) + bufferedSink.close() + fileSystem.atomicMove(tmpFile, file) + evictIfNeeded() + } catch (e: IOException) { + Logger.w(tag = TAG) { "Cache write failed for $pageId/$locale: ${e.message}" } + } + } + + /** Remove all cached translations. */ + suspend fun clear() = mutex.withLock { + try { + if (fileSystem.exists(cacheRoot)) { + fileSystem.deleteRecursively(cacheRoot) + } + } catch (e: IOException) { + Logger.w(tag = TAG) { "Cache clear failed: ${e.message}" } + } + } + + /** Total bytes used by the cache. */ + suspend fun sizeBytes(): Long = mutex.withLock { sizeBytesInternal() } + + private fun sizeBytesInternal(): Long = try { + if (!fileSystem.exists(cacheRoot)) return 0L + fileSystem + .listRecursively(cacheRoot) + .filter { fileSystem.metadata(it).isRegularFile } + .filter { it.name.endsWith(".md") } + .sumOf { fileSystem.metadata(it).size ?: 0L } + } catch (e: IOException) { + Logger.w(tag = TAG) { "Cache size calculation failed: ${e.message}" } + 0L + } + + private fun touchFile(file: Path) { + // Write a tiny sidecar file to track last access time for eviction ordering. + // The sidecar's own mtime serves as the access timestamp. + try { + val accessFile = "$file.access".toPath() + val sink = fileSystem.sink(accessFile).buffer() + sink.writeUtf8("1") + sink.close() + } catch (_: IOException) { + // Non-fatal: eviction order may be slightly off + } + } + + private fun cacheFile(pageId: String, locale: String, sourceHash: String): Path { + val safeKey = "${pageId}_${locale}_$sourceHash".replace(Regex("[^a-zA-Z0-9_-]"), "_") + return cacheRoot / "$safeKey.md" + } + + private fun evictIfNeeded() { + if (sizeBytesInternal() <= maxCacheSizeBytes) return + + try { + val files = + fileSystem + .listRecursively(cacheRoot) + .filter { fileSystem.metadata(it).isRegularFile } + .filter { it.name.endsWith(".md") } + .sortedBy { file -> + // Use sidecar access file mtime if available, else cache file mtime + val accessFile = "$file.access".toPath() + if (fileSystem.exists(accessFile)) { + fileSystem.metadata(accessFile).lastModifiedAtMillis ?: 0L + } else { + fileSystem.metadata(file).lastModifiedAtMillis ?: 0L + } + } + .toList() + + var currentSize = sizeBytesInternal() + for (file in files) { + if (currentSize <= maxCacheSizeBytes * EVICTION_TARGET_PERCENT / PERCENT_DIVISOR) break + val fileSize = fileSystem.metadata(file).size ?: 0L + fileSystem.delete(file) + // Also delete sidecar access file + val accessFile = "$file.access".toPath() + try { + fileSystem.delete(accessFile) + } catch (_: IOException) { + /* ignore */ + } + currentSize -= fileSize + } + } catch (e: IOException) { + Logger.w(tag = TAG) { "Cache eviction failed: ${e.message}" } + } + } +} + +/** Simple MD5 hash for cache key generation. Uses Okio's built-in hashing. */ +fun md5Hash(content: String): String { + val buffer = okio.Buffer() + buffer.writeUtf8(content) + return buffer.md5().hex() +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationService.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationService.kt new file mode 100644 index 000000000..cdf14af90 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/DocTranslationService.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +/** + * Service interface for translating documentation pages at runtime. + * + * Used as a fallback when Crowdin-translated markdown bundles are not available for the user's locale. Google flavor + * provides ML Kit implementation; fdroid/desktop/iOS provide a no-op that returns [TranslationResult.Unavailable]. + */ +interface DocTranslationService { + /** Translate a markdown page to the target locale. Returns the fully-translated markdown or a status. */ + suspend fun translatePage(pageId: String, markdown: String, targetLocale: String): TranslationResult + + /** Check if translation to the given locale is possible (model downloaded or downloadable). */ + suspend fun isLanguageAvailable(locale: String): Boolean + + /** Download the translation model for a locale. Only meaningful on google flavor. */ + suspend fun downloadLanguageModel(locale: String): DownloadResult +} + +/** Result of a translation attempt. */ +sealed class TranslationResult { + /** Translation succeeded. [translatedMarkdown] contains the complete translated markdown source. */ + data class Success(val translatedMarkdown: String) : TranslationResult() + + /** Translation model needs to be downloaded before translating. */ + data class ModelDownloadRequired(val locale: String, val estimatedSizeMb: Int) : TranslationResult() + + /** Translation is not available on this platform/flavor. Caller should fall back to English. */ + data object Unavailable : TranslationResult() +} + +/** Result of a model download attempt. */ +sealed class DownloadResult { + data object Success : DownloadResult() + + data class Failed(val reason: String) : DownloadResult() +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenter.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenter.kt new file mode 100644 index 000000000..ba34812fe --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenter.kt @@ -0,0 +1,375 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +/** + * Segments markdown into translatable and non-translatable blocks, translates the text portions, and reassembles valid + * markdown. + * + * Preserves: + * - Fenced code blocks (``` or ~~~) + * - Indented code blocks (4+ spaces or tab) + * - Link URLs and image paths (translates link text only) + * - HTML tags + * - Frontmatter (--- delimited YAML) + * + * Translates: + * - Paragraphs, headings, list items, blockquotes, table cell text + */ +@Suppress("TooManyFunctions") +object MarkdownTranslationSegmenter { + + /** + * Translate markdown content by extracting text segments, translating them, and reassembling. + * + * @param markdown The source markdown content + * @param translate A suspend function that translates a plain text string + * @return The translated markdown with structure preserved + */ + @Suppress("CyclomaticComplexMethod", "LongMethod") + suspend fun translateMarkdown(markdown: String, translate: suspend (String) -> String): String { + val lines = markdown.lines() + val result = StringBuilder() + var i = 0 + + // Skip frontmatter + if (lines.isNotEmpty() && lines[0].trim() == "---") { + result.appendLine(lines[0]) + i = 1 + while (i < lines.size) { + result.appendLine(lines[i]) + if (lines[i].trim() == "---") { + i++ + break + } + i++ + } + } + + while (i < lines.size) { + val line = lines[i] + + when { + // Fenced code block + isFencedCodeStart(line) -> { + val fence = extractFence(line) + result.appendLine(line) + i++ + while (i < lines.size && !lines[i].trimStart().startsWith(fence)) { + result.appendLine(lines[i]) + i++ + } + if (i < lines.size) { + result.appendLine(lines[i]) + i++ + } + } + + // Indented code block (4 spaces or tab, and previous line is blank or start) + isIndentedCode(line) && (i == 0 || lines[i - 1].isBlank()) -> { + while (i < lines.size && (isIndentedCode(lines[i]) || lines[i].isBlank())) { + result.appendLine(lines[i]) + i++ + } + } + + // HTML block (starts with <) + line.trimStart().startsWith("<") && isHtmlBlock(line) -> { + result.appendLine(line) + i++ + } + + // Empty line + line.isBlank() -> { + result.appendLine(line) + i++ + } + + // Heading (ATX: # followed by space or end-of-line) + line.trimStart().matches(Regex("^#{1,6}(\\s.*|$)")) -> { + val headingPrefix = + line.substring(0, line.indexOf('#')) + + line.substring(line.indexOf('#')).takeWhile { it == '#' } + + " " + val text = line.substring(headingPrefix.length) + val translated = if (text.isNotBlank()) translate(text) else text + result.appendLine("$headingPrefix$translated") + i++ + } + + // Blockquote + line.trimStart().startsWith(">") -> { + val stripped = line.trimStart().removePrefix(">").trimStart() + val indent = line.takeWhile { it != '>' } + val translated = if (stripped.isNotBlank()) translate(stripped) else stripped + result.appendLine("$indent> $translated") + i++ + } + + // List item (unordered or ordered) + isListItem(line) -> { + val (listPrefix, text) = splitListItem(line) + val translatedText = translateInlineMarkdown(text, translate) + result.appendLine("$listPrefix$translatedText") + i++ + } + + // Table row + line.trimStart().startsWith("|") -> { + if (isTableSeparator(line)) { + result.appendLine(line) + } else { + val translated = translateTableRow(line, translate) + result.appendLine(translated) + } + i++ + } + + // Regular paragraph text + else -> { + val translated = translateInlineMarkdown(line, translate) + result.appendLine(translated) + i++ + } + } + } + + // Remove trailing newline added by appendLine on last line + return result.toString().trimEnd('\n') + } + + /** Translate text while preserving inline markdown elements like links, images, and inline code. */ + @Suppress("CyclomaticComplexMethod", "LongMethod") + private suspend fun translateInlineMarkdown(text: String, translate: suspend (String) -> String): String { + if (text.isBlank()) return text + + val segments = mutableListOf() + var pos = 0 + + while (pos < text.length) { + when { + // Inline code + text[pos] == '`' -> { + val end = text.indexOf('`', pos + 1) + if (end > pos) { + segments.add(Segment.Verbatim(text.substring(pos, end + 1))) + pos = end + 1 + } else { + segments.add(Segment.Translatable(text[pos].toString())) + pos++ + } + } + + // Image: ![alt](url) + text[pos] == '!' && pos + 1 < text.length && text[pos + 1] == '[' -> { + val closeBracket = text.indexOf(']', pos + 2) + if (closeBracket > pos && closeBracket + 1 < text.length && text[closeBracket + 1] == '(') { + val closeParen = text.indexOf(')', closeBracket + 2) + if (closeParen > closeBracket) { + segments.add(Segment.Verbatim(text.substring(pos, closeParen + 1))) + pos = closeParen + 1 + } else { + segments.add(Segment.Translatable(text[pos].toString())) + pos++ + } + } else { + segments.add(Segment.Translatable(text[pos].toString())) + pos++ + } + } + + // Link: [text](url) + text[pos] == '[' -> { + val closeBracket = text.indexOf(']', pos + 1) + if (closeBracket > pos && closeBracket + 1 < text.length && text[closeBracket + 1] == '(') { + val closeParen = text.indexOf(')', closeBracket + 2) + if (closeParen > closeBracket) { + val linkText = text.substring(pos + 1, closeBracket) + val url = text.substring(closeBracket + 2, closeParen) + segments.add(Segment.Link(linkText, url)) + pos = closeParen + 1 + } else { + segments.add(Segment.Translatable(text[pos].toString())) + pos++ + } + } else { + segments.add(Segment.Translatable(text[pos].toString())) + pos++ + } + } + + else -> { + // Accumulate regular text + val start = pos + while (pos < text.length && !isInlineMarker(text, pos)) { + pos++ + } + segments.add(Segment.Translatable(text.substring(start, pos))) + } + } + } + + // Translate all translatable segments + return segments + .joinToString("") { segment -> + when (segment) { + is Segment.Verbatim -> segment.text + + is Segment.Translatable -> + if (segment.text.isNotBlank()) { + // We can't suspend in joinToString, so we pre-translate below + segment.text + } else { + segment.text + } + + is Segment.Link -> "[${segment.text}](${segment.url})" + } + } + .let { assembled -> + // Check if there's anything to translate (text segments or link text) + val translatableText = segments.filterIsInstance().joinToString("") { it.text } + val hasTranslatableLinks = segments.any { it is Segment.Link && it.text.isNotBlank() } + if (translatableText.isBlank() && !hasTranslatableLinks) return@let assembled + + // Translate with preserved inline elements (handles both text and link text) + translateWithPreservedInlines(segments, translate) + } + } + + private suspend fun translateWithPreservedInlines( + segments: List, + translate: suspend (String) -> String, + ): String { + val result = StringBuilder() + // Group consecutive translatables, translate them together, preserve verbatim/links + val buffer = StringBuilder() + + for (segment in segments) { + when (segment) { + is Segment.Translatable -> buffer.append(segment.text) + + is Segment.Verbatim -> { + if (buffer.isNotBlank()) { + result.append(translate(buffer.toString())) + } else { + result.append(buffer) + } + buffer.clear() + result.append(segment.text) + } + + is Segment.Link -> { + if (buffer.isNotBlank()) { + result.append(translate(buffer.toString())) + } else { + result.append(buffer) + } + buffer.clear() + val translatedLinkText = if (segment.text.isNotBlank()) translate(segment.text) else segment.text + result.append("[$translatedLinkText](${segment.url})") + } + } + } + if (buffer.isNotBlank()) { + result.append(translate(buffer.toString())) + } else { + result.append(buffer) + } + + return result.toString() + } + + private suspend fun translateTableRow(line: String, translate: suspend (String) -> String): String { + val cells = line.split("|") + val translated = + cells.map { cell -> + val trimmed = cell.trim() + if (trimmed.isNotBlank()) " ${translate(trimmed)} " else cell + } + return translated.joinToString("|") + } + + private fun isFencedCodeStart(line: String): Boolean { + val trimmed = line.trimStart() + return trimmed.startsWith("```") || trimmed.startsWith("~~~") + } + + private fun isInlineMarker(text: String, pos: Int): Boolean = + text[pos] == '`' || text[pos] == '[' || (text[pos] == '!' && pos + 1 < text.length && text[pos + 1] == '[') + + private fun extractFence(line: String): String { + val trimmed = line.trimStart() + val fenceChar = trimmed[0] + return trimmed.takeWhile { it == fenceChar } + } + + private fun isIndentedCode(line: String): Boolean = line.startsWith(" ") || line.startsWith("\t") + + private fun isHtmlBlock(line: String): Boolean { + val trimmed = line.trimStart().lowercase() + return trimmed.startsWith(" { + val leadingWhitespace = line.takeWhile { it == ' ' || it == '\t' } + val trimmed = line.trimStart() + return when { + trimmed.startsWith("- ") -> Pair("$leadingWhitespace- ", trimmed.removePrefix("- ")) + + trimmed.startsWith("* ") -> Pair("$leadingWhitespace* ", trimmed.removePrefix("* ")) + + trimmed.startsWith("+ ") -> Pair("$leadingWhitespace+ ", trimmed.removePrefix("+ ")) + + else -> { + val match = ORDERED_LIST_REGEX.find(trimmed) + if (match != null) { + Pair("$leadingWhitespace${match.value}", trimmed.removePrefix(match.value)) + } else { + Pair(leadingWhitespace, trimmed) + } + } + } + } + + private fun isTableSeparator(line: String): Boolean = + line.replace("|", "").replace("-", "").replace(":", "").replace(" ", "").isEmpty() + + private sealed class Segment { + data class Translatable(val text: String) : Segment() + + data class Verbatim(val text: String) : Segment() + + data class Link(val text: String, val url: String) : Segment() + } + + private val ORDERED_LIST_REGEX = Regex("^\\d+[.)]+\\s") +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/NoOpDocTranslator.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/NoOpDocTranslator.kt new file mode 100644 index 000000000..5a86df6c8 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/NoOpDocTranslator.kt @@ -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 . + */ +package org.meshtastic.feature.docs.translation + +/** + * No-op translation service for platforms without on-device translation capability (F-Droid, Desktop, iOS). + * + * Always returns [TranslationResult.Unavailable], causing the caller to fall back to English. + */ +class NoOpDocTranslator : DocTranslationService { + override suspend fun translatePage(pageId: String, markdown: String, targetLocale: String): TranslationResult = + TranslationResult.Unavailable + + override suspend fun isLanguageAvailable(locale: String): Boolean = false + + override suspend fun downloadLanguageModel(locale: String): DownloadResult = + DownloadResult.Failed("Translation not available on this platform") +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt new file mode 100644 index 000000000..a579f7b7e --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.SuggestionChipDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.m3.Markdown +import org.jetbrains.compose.resources.painterResource +import org.meshtastic.core.resources.img_chirpy +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Send +import org.meshtastic.feature.docs.model.AIDocAssistantSessionState +import org.meshtastic.feature.docs.model.ChirpyMessage +import org.meshtastic.feature.docs.model.ChirpyRole +import org.meshtastic.core.resources.Res as CoreRes + +/** Chirpy AI Assistant bottom sheet with chat UI. Hidden entirely when the assistant reports unsupported. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChirpyAssistantSheet( + state: AIDocAssistantSessionState, + isSupported: Boolean, + onDraftChange: (String) -> Unit, + onSubmit: () -> Unit, + onDismiss: () -> Unit, + onNavigateToPage: (String) -> Unit, + modifier: Modifier = Modifier, +) { + if (!isSupported) return + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) { + Column(modifier = Modifier.fillMaxSize().imePadding().padding(16.dp)) { + Text( + text = "Chirpy Assistant", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + // Message list + val listState = rememberLazyListState() + LazyColumn(state = listState, modifier = Modifier.weight(1f).fillMaxWidth()) { + items(state.messages, key = { it.id }) { message -> + ChirpyMessageBubble(message = message, onNavigateToPage = onNavigateToPage) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.isLoading) { + item { ThinkingBubble() } + } + } + + // Input bar — matches messaging MessageInput style + val canSend = state.draftQuestion.isNotBlank() && !state.isLoading + val keyboardController = LocalSoftwareKeyboardController.current + + fun doSend() { + if (canSend) { + onSubmit() + keyboardController?.hide() + } + } + + OutlinedTextField( + value = state.draftQuestion, + onValueChange = onDraftChange, + placeholder = { Text("Ask about Meshtastic…") }, + singleLine = false, + maxLines = 3, + shape = RoundedCornerShape(INPUT_CORNER_PERCENT), + keyboardOptions = + KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { doSend() }), + trailingIcon = { + IconButton(onClick = ::doSend, enabled = canSend) { + Icon(imageVector = MeshtasticIcons.Send, contentDescription = "Send") + } + }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + } +} + +private val AVATAR_SIZE = 24.dp +private val BUBBLE_CORNER = 8.dp +private val BUBBLE_BORDER_WIDTH = 0.5.dp +private const val INPUT_CORNER_PERCENT = 50f + +/** User bubble shape: rounded everywhere except bottom-end (like MessageItem sender). */ +private val UserBubbleShape = + RoundedCornerShape(topStart = BUBBLE_CORNER, topEnd = BUBBLE_CORNER, bottomStart = BUBBLE_CORNER, bottomEnd = 0.dp) + +/** Chirpy bubble shape: rounded everywhere except top-start (like MessageItem receiver). */ +private val ChirpyBubbleShape = + RoundedCornerShape(topStart = 0.dp, topEnd = BUBBLE_CORNER, bottomStart = BUBBLE_CORNER, bottomEnd = BUBBLE_CORNER) + +@Composable +private fun ChirpyMessageBubble( + message: ChirpyMessage, + onNavigateToPage: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val isUser = message.role == ChirpyRole.USER + + if (isUser) { + UserBubble(message = message, modifier = modifier) + } else { + AssistantBubble(message = message, onNavigateToPage = onNavigateToPage, modifier = modifier) + } +} + +@Composable +private fun UserBubble(message: ChirpyMessage, modifier: Modifier = Modifier) { + val bubbleColor = MaterialTheme.colorScheme.primaryContainer + val borderColor = MaterialTheme.colorScheme.primary + + Column(modifier = modifier.fillMaxWidth().padding(start = 48.dp), horizontalAlignment = Alignment.End) { + Surface( + shape = UserBubbleShape, + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(BUBBLE_BORDER_WIDTH, borderColor), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } +} + +/** Simplified NodeChip-style label shown above Chirpy's message bubbles. */ +@Composable +private fun ChirpyChip(modifier: Modifier = Modifier) { + Card( + modifier = modifier.height(CHIRPY_CHIP_HEIGHT), + shape = MaterialTheme.shapes.small, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 6.dp).height(CHIRPY_CHIP_HEIGHT), + ) { + Image( + painter = painterResource(CoreRes.drawable.img_chirpy), + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "Chirpy", + fontSize = MaterialTheme.typography.labelLarge.fontSize, + textAlign = TextAlign.Center, + maxLines = 1, + ) + } + } +} + +private val CHIRPY_CHIP_HEIGHT = 28.dp + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun AssistantBubble(message: ChirpyMessage, onNavigateToPage: (String) -> Unit, modifier: Modifier = Modifier) { + val bubbleColor = MaterialTheme.colorScheme.surfaceVariant + val borderColor = MaterialTheme.colorScheme.outline + + Column(modifier = modifier.fillMaxWidth().padding(end = 48.dp), horizontalAlignment = Alignment.Start) { + // NodeChip-style sender label above the bubble (like MessageItem) + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + ChirpyChip() + } + + Surface( + shape = ChirpyBubbleShape, + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(BUBBLE_BORDER_WIDTH, borderColor), + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Markdown(content = message.text, modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp)) + } + + if (message.sources.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 4.dp, start = 8.dp), + ) { + message.sources.forEach { source -> + SuggestionChip( + onClick = { onNavigateToPage(source.id) }, + label = { Text(text = source.title, style = MaterialTheme.typography.labelSmall) }, + colors = + SuggestionChipDefaults.suggestionChipColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + labelColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + border = BorderStroke(BUBBLE_BORDER_WIDTH, MaterialTheme.colorScheme.outline), + ) + } + } + } + } +} + +/** Thinking bubble — shows while Chirpy generates a response, styled as an assistant bubble with pulsing alpha. */ +@Composable +private fun ThinkingBubble(modifier: Modifier = Modifier) { + val infiniteTransition = rememberInfiniteTransition(label = "thinking") + val alpha by + infiniteTransition.animateFloat( + initialValue = 0.4f, + targetValue = 1f, + animationSpec = + infiniteRepeatable(animation = tween(durationMillis = 800), repeatMode = RepeatMode.Reverse), + label = "thinkingAlpha", + ) + + val bubbleColor = MaterialTheme.colorScheme.surfaceVariant + val borderColor = MaterialTheme.colorScheme.outline + + Column(modifier = modifier.fillMaxWidth().padding(end = 48.dp), horizontalAlignment = Alignment.Start) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ChirpyChip() + } + + Surface( + shape = ChirpyBubbleShape, + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(BUBBLE_BORDER_WIDTH, borderColor), + modifier = Modifier.padding(horizontal = 8.dp), + ) { + Text( + text = "Chirpy is thinking…", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp).alpha(alpha), + ) + } + } +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ComposeResourceImageTransformer.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ComposeResourceImageTransformer.kt new file mode 100644 index 000000000..31bfd82b3 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ComposeResourceImageTransformer.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.painter.Painter +import coil3.compose.AsyncImagePainter +import coil3.compose.LocalPlatformContext +import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import com.mikepenz.markdown.model.ImageData +import com.mikepenz.markdown.model.ImageTransformer +import meshtasticandroid.feature.docs.generated.resources.Res +import org.jetbrains.compose.resources.MissingResourceException + +/** + * Resolves local markdown image references (e.g. `assets/screenshots/foo.png`) to bundled Compose resources via + * [Res.getUri] and loads them asynchronously using Coil 3's [rememberAsyncImagePainter]. + * + * External URLs (`http://` / `https://`) return `null` so the default renderer behaviour applies (or they are simply + * skipped). Missing resources are silently skipped (returns `null`) to avoid crashing composition when screenshots have + * not yet been generated or synced. + * + * FR-038: Screenshots synced by `syncDocsToComposeResources` land under + * `composeResources/files/docs/assets/screenshots/`, matching the relative paths used in the authored markdown. + */ +class ComposeResourceImageTransformer : ImageTransformer { + + @Composable + override fun transform(link: String): ImageData? { + if (link.startsWith("http://") || link.startsWith("https://")) return null + + // Markdown uses root-relative paths (/assets/screenshots/foo.png) for Jekyll compatibility. + // Strip the leading slash to build the compose resource path. + val relativePath = link.removePrefix("/") + val resourcePath = "files/docs/$relativePath" + val uri = + try { + Res.getUri(resourcePath) + } catch (_: MissingResourceException) { + null + } + + return uri?.let { resolvedUri -> + val painter = + rememberAsyncImagePainter( + model = + ImageRequest.Builder(LocalPlatformContext.current) + .data(resolvedUri) + .size(coil3.size.Size.ORIGINAL) + .build(), + ) + ImageData(painter) + } + } + + @Composable + override fun intrinsicSize(painter: Painter): Size { + var size by remember(painter) { mutableStateOf(painter.intrinsicSize) } + if (painter is AsyncImagePainter) { + val painterState = painter.state.collectAsState() + painterState.value.painter?.intrinsicSize?.also { size = it } + } + return size + } +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt new file mode 100644 index 000000000..b2877bd53 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import org.meshtastic.core.ui.icon.Altitude +import org.meshtastic.core.ui.icon.Antenna +import org.meshtastic.core.ui.icon.BluetoothConnected +import org.meshtastic.core.ui.icon.BugReport +import org.meshtastic.core.ui.icon.Chart +import org.meshtastic.core.ui.icon.ConfigChannels +import org.meshtastic.core.ui.icon.Device +import org.meshtastic.core.ui.icon.ForkLeft +import org.meshtastic.core.ui.icon.Group +import org.meshtastic.core.ui.icon.Language +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Message +import org.meshtastic.core.ui.icon.Nodes +import org.meshtastic.core.ui.icon.Notes +import org.meshtastic.core.ui.icon.PersonSearch +import org.meshtastic.core.ui.icon.PinDrop +import org.meshtastic.core.ui.icon.Rssi +import org.meshtastic.core.ui.icon.Settings +import org.meshtastic.core.ui.icon.SignalCellular3Bar +import org.meshtastic.core.ui.icon.WavingHand +import org.meshtastic.feature.docs.model.DocPage + +/** Resolves a [DocPage.iconId] to a [ImageVector] from [MeshtasticIcons]. */ +@Composable +@Suppress("CyclomaticComplexMethod") +internal fun DocPage.resolveIcon(): ImageVector = when (iconId) { + // User Guide + "onboarding" -> MeshtasticIcons.WavingHand + + "connections" -> MeshtasticIcons.BluetoothConnected + + "messages" -> MeshtasticIcons.Message + + "nodes" -> MeshtasticIcons.Nodes + + "node-metrics" -> MeshtasticIcons.Chart + + "map" -> MeshtasticIcons.PinDrop + + "settings-radio" -> MeshtasticIcons.Settings + + "settings-module" -> MeshtasticIcons.ConfigChannels + + "telemetry" -> MeshtasticIcons.Altitude + + "tak" -> MeshtasticIcons.Antenna + + "mqtt" -> MeshtasticIcons.Rssi + + "discovery" -> MeshtasticIcons.PersonSearch + + "firmware" -> MeshtasticIcons.Device + + "desktop" -> MeshtasticIcons.Device + + "signal-meter" -> MeshtasticIcons.SignalCellular3Bar + + "units-locale" -> MeshtasticIcons.Language + + "translate" -> MeshtasticIcons.Language + + // Developer Guide + "architecture" -> MeshtasticIcons.ForkLeft + + "codebase" -> MeshtasticIcons.ForkLeft + + "adding-features" -> MeshtasticIcons.ForkLeft + + "navigation" -> MeshtasticIcons.ForkLeft + + "transport" -> MeshtasticIcons.Antenna + + "persistence" -> MeshtasticIcons.Chart + + "testing" -> MeshtasticIcons.BugReport + + "contributing" -> MeshtasticIcons.Group + + "measurement" -> MeshtasticIcons.Chart + + else -> MeshtasticIcons.Notes +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt new file mode 100644 index 000000000..6e09d11e6 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt @@ -0,0 +1,205 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.meshtastic.core.resources.img_chirpy +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.feature.docs.model.AIDocAssistantSessionState +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocSection +import org.meshtastic.core.resources.Res as CoreRes + +/** Main documentation browser screen showing a grouped TOC. */ +@Suppress("LongMethod", "LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocsBrowserScreen( + pages: List, + isLoading: Boolean, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSelectPage: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + isAiSupported: Boolean = false, + showFab: Boolean = false, + showChirpy: Boolean = false, + chirpyState: AIDocAssistantSessionState = AIDocAssistantSessionState(), + onChirpyToggle: () -> Unit = {}, + onChirpyDismiss: () -> Unit = {}, + onChirpyDraftChange: (String) -> Unit = {}, + onChirpySubmit: () -> Unit = {}, + onChirpyNavigateToPage: (String) -> Unit = {}, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text("Help & Documentation") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = MeshtasticIcons.ArrowBack, contentDescription = "Navigate back") + } + }, + ) + }, + floatingActionButton = { + if (isAiSupported && showFab) { + FloatingActionButton(onClick = onChirpyToggle) { + Image( + painter = painterResource(CoreRes.drawable.img_chirpy), + contentDescription = "Ask Chirpy", + modifier = Modifier.size(32.dp), + ) + } + } + }, + modifier = modifier, + ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + DocsSearchBar( + query = searchQuery, + onQueryChange = onSearchQueryChange, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + ) + + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + Text( + text = "Loading documentation...", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 16.dp), + ) + } + } + + pages.isEmpty() -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = if (searchQuery.isNotBlank()) "No results found" else "No documentation available", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + else -> { + DocsTocList(pages = pages, onSelectPage = onSelectPage) + } + } + } + + if (showChirpy) { + ChirpyAssistantSheet( + state = chirpyState, + isSupported = isAiSupported, + onDraftChange = onChirpyDraftChange, + onSubmit = onChirpySubmit, + onDismiss = onChirpyDismiss, + onNavigateToPage = onChirpyNavigateToPage, + ) + } + } +} + +@Composable +private fun DocsTocList(pages: List, onSelectPage: (String) -> Unit, modifier: Modifier = Modifier) { + val userGuidePages = + remember(pages) { pages.filter { it.section == DocSection.UserGuide }.sortedBy { it.navOrder } } + val devGuidePages = + remember(pages) { pages.filter { it.section == DocSection.DeveloperGuide }.sortedBy { it.navOrder } } + + LazyColumn(modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 16.dp)) { + if (userGuidePages.isNotEmpty()) { + item { + Text( + text = "User Guide", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).semantics { heading() }, + ) + } + items(userGuidePages, key = { it.id }) { page -> DocPageListItem(page = page, onSelectPage = onSelectPage) } + } + + if (devGuidePages.isNotEmpty()) { + item { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = "Developer Guide", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp).semantics { heading() }, + ) + } + items(devGuidePages, key = { it.id }) { page -> DocPageListItem(page = page, onSelectPage = onSelectPage) } + } + } +} + +@Composable +private fun DocPageListItem(page: DocPage, onSelectPage: (String) -> Unit, modifier: Modifier = Modifier) { + ListItem( + leadingContent = { + Icon( + imageVector = page.resolveIcon(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + headlineContent = { Text(page.title) }, + modifier = modifier.clickable { onSelectPage(page.id) }.semantics { contentDescription = "Open ${page.title}" }, + ) +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt new file mode 100644 index 000000000..3410d60e7 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.mikepenz.markdown.compose.components.markdownComponents +import com.mikepenz.markdown.compose.elements.MarkdownTable +import com.mikepenz.markdown.compose.elements.MarkdownTableHeader +import com.mikepenz.markdown.compose.elements.MarkdownTableRow +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.model.markdownDimens +import org.jetbrains.compose.resources.painterResource +import org.meshtastic.core.resources.img_chirpy +import org.meshtastic.core.ui.icon.ArrowBack +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.feature.docs.model.AIDocAssistantSessionState +import org.meshtastic.feature.docs.model.DocPageContent +import org.meshtastic.feature.docs.model.TranslationSource +import org.meshtastic.core.resources.Res as CoreRes + +/** Routes a page ID to the appropriate page renderer surface. */ +@Suppress("LongMethod", "LongParameterList") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocsPageRouteScreen( + pageId: String, + content: DocPageContent?, + isLoading: Boolean, + translationSource: TranslationSource = TranslationSource.BUNDLED, + isNonEnglish: Boolean = false, + isAiSupported: Boolean = false, + showChirpy: Boolean = false, + chirpyState: AIDocAssistantSessionState = AIDocAssistantSessionState(), + onChirpyToggle: () -> Unit = {}, + onChirpyDismiss: () -> Unit = {}, + onChirpyDraftChange: (String) -> Unit = {}, + onChirpySubmit: () -> Unit = {}, + onChirpyNavigateToPage: (String) -> Unit = {}, + onBack: () -> Unit, + onNavigateToPage: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = content?.page?.title ?: "Documentation", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isNonEnglish && !isLoading && content != null) { + Text( + text = + when (translationSource) { + TranslationSource.ML_KIT -> "Auto-translated" + TranslationSource.BUNDLED -> "Community translated" + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = MeshtasticIcons.ArrowBack, contentDescription = "Navigate back") + } + }, + ) + }, + floatingActionButton = { + if (isAiSupported) { + FloatingActionButton(onClick = onChirpyToggle) { + Image( + painter = painterResource(CoreRes.drawable.img_chirpy), + contentDescription = "Ask Chirpy", + modifier = Modifier.size(32.dp), + ) + } + } + }, + modifier = modifier, + ) { innerPadding -> + Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CircularProgressIndicator() + } + } + + content == null -> { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "Page not found: $pageId", style = MaterialTheme.typography.bodyLarge) + Text( + text = "This page may have been moved or removed.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp), + ) + } + } + + else -> { + val markdownText = content.markdown ?: "No content available." + val platformUriHandler = LocalUriHandler.current + val docsUriHandler = + remember(platformUriHandler) { + DocsLinkUriHandler(onNavigateToPage = onNavigateToPage, fallback = platformUriHandler) + } + CompositionLocalProvider(LocalUriHandler provides docsUriHandler) { + Markdown( + content = markdownText, + imageTransformer = ComposeResourceImageTransformer(), + dimens = markdownDimens(tableCellWidth = 108.dp), + components = + markdownComponents( + table = { + MarkdownTable( + content = it.content, + node = it.node, + style = it.typography.text, + headerBlock = { c, h, tw, s -> + MarkdownTableHeader( + content = c, + header = h, + tableWidth = tw, + style = s, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Clip, + ) + }, + rowBlock = { c, r, tw, s -> + MarkdownTableRow( + content = c, + header = r, + tableWidth = tw, + style = s, + maxLines = Int.MAX_VALUE, + overflow = TextOverflow.Clip, + ) + }, + ) + }, + ), + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + ) + } + } + } + } + + if (showChirpy) { + ChirpyAssistantSheet( + state = chirpyState, + isSupported = isAiSupported, + onDraftChange = onChirpyDraftChange, + onSubmit = onChirpySubmit, + onDismiss = onChirpyDismiss, + onNavigateToPage = onChirpyNavigateToPage, + ) + } + } +} + +/** + * Custom [UriHandler] that intercepts relative doc links and navigates in-app. + * + * Relative links like `connections`, `../developer/architecture`, or anchor-only `#section` are resolved to a page ID + * and dispatched via [onNavigateToPage]. External `http(s)://` URLs are forwarded to the [fallback] platform handler. + */ +internal class DocsLinkUriHandler(private val onNavigateToPage: (String) -> Unit, private val fallback: UriHandler) : + UriHandler { + override fun openUri(uri: String) { + if (uri.startsWith("http://") || uri.startsWith("https://")) { + fallback.openUri(uri) + return + } + // Anchor-only links (e.g. "#permissions") — ignore, stay on current page + if (uri.startsWith("#")) return + + // Resolve relative path to a page ID: + // "connections" -> "connections" + // "../developer/architecture" -> "architecture" + // "mqtt.html" -> "mqtt" + val cleaned = + uri.substringBefore('#') // strip anchor + .substringAfterLast('/') // take filename segment + .removeSuffix(".html") // strip .html if present + .removeSuffix(".md") // strip .md if present + + if (cleaned.isNotBlank()) { + onNavigateToPage(cleaned) + } + } +} diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPreviews.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPreviews.kt new file mode 100644 index 000000000..c08a620c6 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPreviews.kt @@ -0,0 +1,509 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.painterResource +import org.meshtastic.core.resources.img_chirpy +import org.meshtastic.core.ui.theme.AppTheme +import org.meshtastic.feature.docs.model.AIDocAssistantSessionState +import org.meshtastic.feature.docs.model.ChirpyMessage +import org.meshtastic.feature.docs.model.ChirpyRole +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocSection +import org.meshtastic.feature.docs.model.SourceRef +import org.meshtastic.core.resources.Res as CoreRes + +private val sampleUserGuidePages = + listOf( + DocPage( + "onboarding", + "Getting Started", + DocSection.UserGuide, + 1, + "user/onboarding.md", + listOf("setup", "intro"), + charCount = 3200, + iconId = "onboarding", + ), + DocPage( + "connections", + "Connections", + DocSection.UserGuide, + 2, + "user/connections.md", + listOf("bluetooth", "usb"), + charCount = 4100, + iconId = "connections", + ), + DocPage( + "messages-and-channels", + "Messages & Channels", + DocSection.UserGuide, + 3, + "user/messages-and-channels.md", + listOf("chat"), + charCount = 5200, + iconId = "messages", + ), + DocPage( + "nodes", + "Nodes", + DocSection.UserGuide, + 4, + "user/nodes.md", + listOf("node list"), + charCount = 3800, + iconId = "nodes", + ), + DocPage( + "node-metrics", + "Node Metrics", + DocSection.UserGuide, + 5, + "user/node-metrics.md", + listOf("telemetry"), + charCount = 6200, + iconId = "node-metrics", + ), + DocPage( + "map-and-waypoints", + "Map & Waypoints", + DocSection.UserGuide, + 6, + "user/map-and-waypoints.md", + listOf("map"), + charCount = 4500, + iconId = "map", + ), + DocPage( + "settings-radio-user", + "Radio & User Settings", + DocSection.UserGuide, + 7, + "user/settings-radio-user.md", + listOf("config"), + charCount = 5800, + iconId = "settings-radio", + ), + DocPage( + "firmware", + "Firmware Updates", + DocSection.UserGuide, + 9, + "user/firmware.md", + listOf("ota"), + charCount = 3400, + iconId = "firmware", + ), + DocPage( + "signal-meter", + "Signal Meter", + DocSection.UserGuide, + 15, + "user/signal-meter.md", + listOf("rssi", "snr"), + charCount = 3500, + iconId = "signal-meter", + ), + DocPage( + "units-and-locale", + "Units & Locale", + DocSection.UserGuide, + 16, + "user/units-and-locale.md", + listOf("metric", "imperial"), + charCount = 3800, + iconId = "units-locale", + ), + ) + +private val sampleDevGuidePages = + listOf( + DocPage( + "architecture", + "Architecture", + DocSection.DeveloperGuide, + 1, + "developer/architecture.md", + listOf("kmp"), + charCount = 4800, + iconId = "architecture", + ), + DocPage( + "codebase", + "Codebase Layout", + DocSection.DeveloperGuide, + 2, + "developer/codebase.md", + listOf("structure"), + charCount = 3600, + iconId = "codebase", + ), + DocPage( + "contributing", + "Contributing", + DocSection.DeveloperGuide, + 8, + "developer/contributing.md", + listOf("pr"), + charCount = 2900, + iconId = "contributing", + ), + ) + +private val sampleAllPages = sampleUserGuidePages + sampleDevGuidePages + +private val sampleMarkdown = + """ + # Getting Started + + Welcome to Meshtastic! This guide walks you through the initial setup. + + ## First Launch + + When you open the app for the first time, you'll be guided through an introductory + flow that helps configure essential permissions and settings. + + ### Permissions + + The app requires several permissions to operate correctly: + + - **Location** — Required for Bluetooth scanning + - **Bluetooth** — Primary connection method + - **Notifications** — Incoming message alerts + + > 💡 **Tip:** You can change notification preferences later in Android system settings. + """ + .trimIndent() + +private val sampleChirpyMessages = + listOf( + ChirpyMessage("1", ChirpyRole.USER, "How do I connect to my radio?"), + ChirpyMessage( + "2", + ChirpyRole.ASSISTANT, + "To connect to your Meshtastic radio via Bluetooth:\n\n" + + "1. Power on your radio\n2. Open the app → Connections\n" + + "3. Tap Scan for Devices\n4. Select your device from the list\n\n" + + "Make sure Bluetooth and Location permissions are granted.", + sources = listOf(SourceRef("connections", "Connections"), SourceRef("onboarding", "Getting Started")), + ), + ChirpyMessage("3", ChirpyRole.USER, "What if my device doesn't appear in the scan?"), + ) + +// region DocsBrowserScreen Previews + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsBrowserScreenPreview() { + AppTheme { + DocsBrowserScreen( + pages = sampleAllPages, + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } +} + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsBrowserScreenEmptyPreview() { + AppTheme { + DocsBrowserScreen( + pages = emptyList(), + isLoading = false, + searchQuery = "xyzzy", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } +} + +// endregion + +// region DocsPageRouteScreen Previews + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsPageContentPreview() { + AppTheme { + DocsPageRouteScreen( + pageId = "onboarding", + content = + org.meshtastic.feature.docs.model.DocPageContent( + page = sampleUserGuidePages.first(), + markdown = sampleMarkdown, + ), + isLoading = false, + onBack = {}, + ) + } +} + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsPageNotFoundPreview() { + AppTheme { DocsPageRouteScreen(pageId = "deleted-page", content = null, isLoading = false, onBack = {}) } +} + +// endregion + +// region ChirpyAssistant Previews + +/** + * Previews the Chirpy assistant chat content without ModalBottomSheet wrapper, since ModalBottomSheet requires a sheet + * host that is unavailable in previews. + */ +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun ChirpyAssistantContentPreview() { + AppTheme { + Surface { + ChirpyAssistantContent( + state = + AIDocAssistantSessionState(messages = sampleChirpyMessages, isLoading = false, draftQuestion = ""), + onDraftChange = {}, + onSubmit = {}, + onNavigateToPage = {}, + ) + } + } +} + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun ChirpyAssistantLoadingPreview() { + AppTheme { + Surface { + ChirpyAssistantContent( + state = + AIDocAssistantSessionState( + messages = sampleChirpyMessages, + isLoading = true, + draftQuestion = "What channels should I use?", + ), + onDraftChange = {}, + onSubmit = {}, + onNavigateToPage = {}, + ) + } + } +} + +/** Standalone chat content layout extracted for preview compatibility. */ +@Composable +fun ChirpyAssistantContent( + state: AIDocAssistantSessionState, + onDraftChange: (String) -> Unit, + onSubmit: () -> Unit, + onNavigateToPage: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Text( + text = "Chirpy Assistant", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) { + items(state.messages, key = { it.id }) { message -> + ChirpyBubble(message = message, onNavigateToPage = onNavigateToPage) + Spacer(modifier = Modifier.height(8.dp)) + } + + if (state.isLoading) { + item { PreviewThinkingBubble() } + } + } + + OutlinedTextField( + value = state.draftQuestion, + onValueChange = onDraftChange, + placeholder = { Text("Ask about Meshtastic...") }, + trailingIcon = { + TextButton(onClick = onSubmit, enabled = state.draftQuestion.isNotBlank() && !state.isLoading) { + Text("Send") + } + }, + singleLine = false, + maxLines = 3, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } +} + +private val PreviewBubbleCorner = 8.dp + +@Suppress("LongMethod") +@Composable +private fun ChirpyBubble(message: ChirpyMessage, onNavigateToPage: (String) -> Unit, modifier: Modifier = Modifier) { + val isUser = message.role == ChirpyRole.USER + + if (isUser) { + val bubbleColor = MaterialTheme.colorScheme.primaryContainer + val borderColor = MaterialTheme.colorScheme.primary + Column(modifier = modifier.fillMaxWidth().padding(start = 48.dp), horizontalAlignment = Alignment.End) { + Surface( + shape = RoundedCornerShape(PreviewBubbleCorner, PreviewBubbleCorner, 0.dp, PreviewBubbleCorner), + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(0.5.dp, borderColor), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } else { + val bubbleColor = MaterialTheme.colorScheme.surfaceVariant + val borderColor = MaterialTheme.colorScheme.outline + Column(modifier = modifier.fillMaxWidth().padding(end = 48.dp), horizontalAlignment = Alignment.Start) { + Row(verticalAlignment = Alignment.Top) { + Image( + painter = painterResource(CoreRes.drawable.img_chirpy), + contentDescription = null, + modifier = Modifier.size(24.dp).padding(top = 2.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(0.dp, PreviewBubbleCorner, PreviewBubbleCorner, PreviewBubbleCorner), + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(0.5.dp, borderColor), + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + + if (message.sources.isNotEmpty()) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(top = 4.dp, start = 30.dp), + ) { + message.sources.forEach { source -> + Surface( + shape = RoundedCornerShape(PreviewBubbleCorner), + color = MaterialTheme.colorScheme.secondaryContainer, + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outline), + onClick = { onNavigateToPage(source.id) }, + ) { + Text( + text = source.title, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + } + } +} + +/** Preview-safe thinking bubble without infinite animations. */ +@Composable +private fun PreviewThinkingBubble(modifier: Modifier = Modifier) { + val bubbleColor = MaterialTheme.colorScheme.surfaceVariant + val borderColor = MaterialTheme.colorScheme.outline + + Column(modifier = modifier.fillMaxWidth().padding(end = 48.dp), horizontalAlignment = Alignment.Start) { + Row(verticalAlignment = Alignment.Top) { + Image( + painter = painterResource(CoreRes.drawable.img_chirpy), + contentDescription = null, + modifier = Modifier.size(24.dp).padding(top = 2.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Surface( + shape = RoundedCornerShape(0.dp, PreviewBubbleCorner, PreviewBubbleCorner, PreviewBubbleCorner), + color = bubbleColor, + contentColor = MaterialTheme.colorScheme.onSurface, + border = BorderStroke(0.5.dp, borderColor), + ) { + Text( + text = "Chirpy is thinking…", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), + ) + } + } + } +} + +// endregion + +// region DocsSearchBar Previews + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsSearchBarEmptyPreview() { + AppTheme { Surface { DocsSearchBar(query = "", onQueryChange = {}, modifier = Modifier.padding(16.dp)) } } +} + +@Suppress("PreviewPublic") +@PreviewLightDark +@Composable +fun DocsSearchBarWithQueryPreview() { + AppTheme { + Surface { DocsSearchBar(query = "bluetooth settings", onQueryChange = {}, modifier = Modifier.padding(16.dp)) } + } +} + +// endregion diff --git a/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt new file mode 100644 index 000000000..32ccd5837 --- /dev/null +++ b/feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.doc_clear_search +import org.meshtastic.core.resources.doc_search_placeholder +import org.meshtastic.core.ui.icon.Close +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.core.ui.icon.Search + +/** Search bar for filtering documentation pages by keywords. */ +@Composable +fun DocsSearchBar(query: String, onQueryChange: (String) -> Unit, modifier: Modifier = Modifier) { + val searchPlaceholder = stringResource(Res.string.doc_search_placeholder) + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(searchPlaceholder) }, + leadingIcon = { Icon(imageVector = MeshtasticIcons.Search, contentDescription = null) }, + trailingIcon = { + if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = MeshtasticIcons.Close, + contentDescription = stringResource(Res.string.doc_clear_search), + ) + } + } + }, + singleLine = true, + modifier = modifier.fillMaxWidth().semantics { contentDescription = searchPlaceholder }, + ) +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocBundleLoaderTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocBundleLoaderTest.kt new file mode 100644 index 000000000..472ab8582 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocBundleLoaderTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs + +import kotlinx.coroutines.test.runTest +import org.meshtastic.feature.docs.data.DefaultDocBundleLoader +import org.meshtastic.feature.docs.model.DocSection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class DocBundleLoaderTest { + + private val loader = DefaultDocBundleLoader() + + @Test + fun `load returns non-empty bundle`() = runTest { + val bundle = loader.load() + assertTrue(bundle.pages.isNotEmpty()) + assertEquals(bundle.pages.size, bundle.pageIndex.size) + } + + @Test + fun `page index keys match page ids`() = runTest { + val bundle = loader.load() + assertEquals(bundle.pages.map { it.id }.toSet(), bundle.pageIndex.keys) + } + + @Test + fun `pages by section returns correct grouping`() = runTest { + loader.load() + val userPages = loader.pagesBySection(DocSection.UserGuide) + val devPages = loader.pagesBySection(DocSection.DeveloperGuide) + + assertTrue(userPages.isNotEmpty()) + assertTrue(devPages.isNotEmpty()) + assertTrue(userPages.all { it.section == DocSection.UserGuide }) + assertTrue(devPages.all { it.section == DocSection.DeveloperGuide }) + } + + @Test + fun `pages by section are sorted by navOrder`() = runTest { + loader.load() + val userPages = loader.pagesBySection(DocSection.UserGuide) + val navOrders = userPages.map { it.navOrder } + assertEquals(navOrders.sorted(), navOrders) + } + + @Test + fun `read page returns content for valid page id`() = runTest { + val content = loader.readPage("onboarding") + assertNotNull(content) + assertEquals("onboarding", content.page.id) + assertEquals("Getting Started", content.page.title) + } + + @Test + fun `read page returns null for invalid page id`() = runTest { + val content = loader.readPage("nonexistent-page") + assertEquals(null, content) + } + + @Test + fun `all pages have positive char count`() = runTest { + val bundle = loader.load() + assertTrue(bundle.pages.all { it.charCount > 0 }) + } + + @Test + fun `all pages have non-empty keywords`() = runTest { + val bundle = loader.load() + assertTrue(bundle.pages.all { it.keywords.isNotEmpty() }) + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocsNavigationTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocsNavigationTest.kt new file mode 100644 index 000000000..e62d79270 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/DocsNavigationTest.kt @@ -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 . + */ +package org.meshtastic.feature.docs + +import org.meshtastic.core.navigation.SettingsRoute +import kotlin.test.Test +import kotlin.test.assertEquals + +class DocsNavigationTest { + + @Test + fun `HelpDocs route serializable`() { + val route = SettingsRoute.HelpDocs + assertEquals(SettingsRoute.HelpDocs, route) + } + + @Test + fun `HelpDocPage route preserves page id`() { + val route = SettingsRoute.HelpDocPage("messages-and-channels") + assertEquals("messages-and-channels", route.pageId) + } + + @Test + fun `HelpDocPage route equality`() { + val route1 = SettingsRoute.HelpDocPage("onboarding") + val route2 = SettingsRoute.HelpDocPage("onboarding") + assertEquals(route1, route2) + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/KeywordSearchEngineTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/KeywordSearchEngineTest.kt new file mode 100644 index 000000000..d0601a725 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/KeywordSearchEngineTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs + +import kotlinx.coroutines.test.runTest +import org.meshtastic.feature.docs.data.DefaultDocBundleLoader +import org.meshtastic.feature.docs.data.KeywordSearchEngine +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class KeywordSearchEngineTest { + + private val loader = DefaultDocBundleLoader() + private val engine = KeywordSearchEngine(loader) + + @Test + fun `search for bluetooth returns connections page`() = runTest { + val results = engine.search("bluetooth") + assertTrue(results.isNotEmpty()) + assertEquals("connections", results.first().page.id) + } + + @Test + fun `search for messages returns messages-and-channels page`() = runTest { + val results = engine.search("messages channels") + assertTrue(results.isNotEmpty()) + assertTrue(results.any { it.page.id == "messages-and-channels" }) + } + + @Test + fun `empty query returns no results`() = runTest { + val results = engine.search("") + assertTrue(results.isEmpty()) + } + + @Test + fun `stop words are filtered`() { + val query = engine.normalize("how do I connect the bluetooth") + assertTrue("how" !in query.normalizedTerms) + assertTrue("do" !in query.normalizedTerms) + assertTrue("the" !in query.normalizedTerms) + assertTrue("connect" in query.normalizedTerms) + assertTrue("bluetooth" in query.normalizedTerms) + } + + @Test + fun `title matches score higher than keyword matches`() = runTest { + val results = engine.search("firmware") + assertTrue(results.isNotEmpty()) + // "Firmware Updates" page has "firmware" directly in title — should rank first + assertEquals("firmware", results.first().page.id) + assertTrue(results.first().score >= KeywordSearchEngine.TITLE_MATCH_SCORE) + } + + @Test + fun `alias matches produce results`() = runTest { + val results = engine.search("direct-messages") + assertTrue(results.isNotEmpty()) + assertTrue(results.any { it.page.id == "messages-and-channels" }) + } + + @Test + fun `token budget selection limits pages`() = runTest { + // Budget large enough for some but not all matching pages + val pages = engine.selectForTokenBudget("settings module", maxChars = 15000) + assertTrue(pages.isNotEmpty()) + assertTrue(pages.sumOf { it.charCount } <= 15000) + } + + @Test + fun `results are sorted by score descending`() = runTest { + val results = engine.search("settings module") + if (results.size >= 2) { + for (i in 0 until results.size - 1) { + assertTrue(results[i].score >= results[i + 1].score) + } + } + } + + @Test + fun `search is case insensitive`() = runTest { + val lower = engine.search("mqtt") + val upper = engine.search("MQTT") + assertEquals(lower.map { it.page.id }, upper.map { it.page.id }) + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCacheTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCacheTest.kt new file mode 100644 index 000000000..61642fd46 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/DocTranslationCacheTest.kt @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +import kotlinx.coroutines.test.runTest +import okio.FileSystem +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DocTranslationCacheTest { + + private val cacheDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "docs_cache_test_${kotlin.random.Random.nextInt()}" + private val cache = + DocTranslationCache( + cacheDir = cacheDir, + fileSystem = FileSystem.SYSTEM, + maxCacheSizeBytes = 1024, // 1KB for testing eviction + ) + + @AfterTest + fun tearDown() { + try { + FileSystem.SYSTEM.deleteRecursively(cacheDir) + } catch (_: Exception) { + // best effort cleanup + } + } + + @Test + fun `get returns null for uncached page`() = runTest { + val result = cache.get("onboarding", "es", "abc123") + assertNull(result) + } + + @Test + fun `put then get returns cached content`() = runTest { + cache.put("onboarding", "es", "hash1", "# Bienvenido") + val result = cache.get("onboarding", "es", "hash1") + assertEquals("# Bienvenido", result) + } + + @Test + fun `different hash returns null - stale cache`() = runTest { + cache.put("onboarding", "es", "hash1", "# Bienvenido") + val result = cache.get("onboarding", "es", "hash2") + assertNull(result) + } + + @Test + fun `different locale returns null`() = runTest { + cache.put("onboarding", "es", "hash1", "# Bienvenido") + val result = cache.get("onboarding", "fr", "hash1") + assertNull(result) + } + + @Test + fun `clear removes all cached entries`() = runTest { + cache.put("page1", "es", "h1", "content1") + cache.put("page2", "fr", "h2", "content2") + cache.clear() + assertNull(cache.get("page1", "es", "h1")) + assertNull(cache.get("page2", "fr", "h2")) + } + + @Test + fun `sizeBytes reports total cache size`() = runTest { + assertEquals(0L, cache.sizeBytes()) + cache.put("page1", "es", "h1", "Hello") + assertTrue(cache.sizeBytes() > 0) + } + + @Test + fun `eviction removes oldest entries when over limit`() = runTest { + // Fill cache beyond 1KB limit with multiple entries + val largeContent = "x".repeat(300) + cache.put("page1", "es", "h1", largeContent) + cache.put("page2", "es", "h2", largeContent) + cache.put("page3", "es", "h3", largeContent) + cache.put("page4", "es", "h4", largeContent) // triggers eviction + + // page4 should still be accessible as the newest entry + val page4 = cache.get("page4", "es", "h4") + assertEquals(largeContent, page4) + + // Total size should be at or below max + assertTrue(cache.sizeBytes() <= 1024) + } + + @Test + fun `md5Hash produces consistent hash`() { + val hash1 = md5Hash("Hello, world!") + val hash2 = md5Hash("Hello, world!") + assertEquals(hash1, hash2) + } + + @Test + fun `md5Hash differs for different content`() { + val hash1 = md5Hash("Hello") + val hash2 = md5Hash("World") + assertTrue(hash1 != hash2) + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenterTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenterTest.kt new file mode 100644 index 000000000..4bd6c0a9b --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/MarkdownTranslationSegmenterTest.kt @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class MarkdownTranslationSegmenterTest { + + /** Simple uppercase "translator" for deterministic assertions. */ + private val uppercaseTranslator: suspend (String) -> String = { it.uppercase() } + + @Test + fun `paragraphs are translated`() = runTest { + val input = "Hello world\n\nThis is a paragraph" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "HELLO WORLD") + assertContains(result, "THIS IS A PARAGRAPH") + } + + @Test + fun `headings are translated`() = runTest { + val input = "# Welcome\n## Getting Started" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "# WELCOME") + assertContains(result, "## GETTING STARTED") + } + + @Test + fun `fenced code blocks are NOT translated`() = runTest { + val input = "Before code\n\n```kotlin\nval x = 1\n```\n\nAfter code" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "BEFORE CODE") + assertContains(result, "val x = 1") + assertContains(result, "AFTER CODE") + assertFalse(result.contains("VAL X = 1")) + } + + @Test + fun `indented code blocks are NOT translated`() = runTest { + val input = "Some text\n\n code line 1\n code line 2\n\nMore text" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "SOME TEXT") + assertContains(result, " code line 1") + assertContains(result, "MORE TEXT") + } + + @Test + fun `inline code is NOT translated`() = runTest { + val input = "Use `meshtastic --info` to check" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "`meshtastic --info`") + assertContains(result, "USE") + assertContains(result, "TO CHECK") + } + + @Test + fun `link text is translated but URL is preserved`() = runTest { + val input = "Click [here](https://example.com) for more" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "[HERE](https://example.com)") + assertContains(result, "CLICK") + } + + @Test + fun `images are NOT translated`() = runTest { + val input = "See the diagram:\n\n![alt text](/images/diagram.png)" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "![alt text](/images/diagram.png)") + assertContains(result, "SEE THE DIAGRAM:") + } + + @Test + fun `frontmatter is NOT translated`() = runTest { + val input = "---\ntitle: My Page\nlayout: default\n---\n\nHello world" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "title: My Page") + assertContains(result, "layout: default") + assertContains(result, "HELLO WORLD") + } + + @Test + fun `list items are translated`() = runTest { + val input = "- First item\n- Second item\n* Third item" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "- FIRST ITEM") + assertContains(result, "- SECOND ITEM") + assertContains(result, "* THIRD ITEM") + } + + @Test + fun `ordered list items are translated`() = runTest { + val input = "1. First step\n2. Second step" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "1. FIRST STEP") + assertContains(result, "2. SECOND STEP") + } + + @Test + fun `blockquotes are translated`() = runTest { + val input = "> Important note here" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "> IMPORTANT NOTE HERE") + } + + @Test + fun `table cells are translated but separators are not`() = runTest { + val input = "| Header 1 | Header 2 |\n|---|---|\n| Cell A | Cell B |" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "HEADER 1") + assertContains(result, "HEADER 2") + assertContains(result, "|---|---|") + assertContains(result, "CELL A") + assertContains(result, "CELL B") + } + + @Test + fun `empty input returns empty output`() = runTest { + val result = MarkdownTranslationSegmenter.translateMarkdown("", uppercaseTranslator) + assertEquals("", result) + } + + @Test + fun `html blocks are NOT translated`() = runTest { + val input = "Before\n\n
Don't translate
\n\nAfter" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "
Don't translate
") + assertContains(result, "BEFORE") + assertContains(result, "AFTER") + } + + @Test + fun `nested list indentation is preserved`() = runTest { + val input = "- Parent\n - Child item" + val result = MarkdownTranslationSegmenter.translateMarkdown(input, uppercaseTranslator) + assertContains(result, "- PARENT") + assertContains(result, " - CHILD ITEM") + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/TranslationCascadeTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/TranslationCascadeTest.kt new file mode 100644 index 000000000..980545baa --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/translation/TranslationCascadeTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.translation + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * Tests for the translation cascade decision logic: Crowdin bundled → ML Kit runtime → English fallback. + * + * These tests exercise the service interface contracts without ML Kit (which requires Android). + */ +class TranslationCascadeTest { + + @Test + fun `NoOp translator returns Unavailable for any locale`() = runTest { + val service: DocTranslationService = NoOpDocTranslator() + val result = service.translatePage("onboarding", "# Hello", "es") + assertIs(result) + } + + @Test + fun `NoOp translator reports language unavailable`() = runTest { + val service: DocTranslationService = NoOpDocTranslator() + assertEquals(false, service.isLanguageAvailable("es")) + assertEquals(false, service.isLanguageAvailable("fr")) + assertEquals(false, service.isLanguageAvailable("zh")) + } + + @Test + fun `NoOp translator download returns Failed`() = runTest { + val service: DocTranslationService = NoOpDocTranslator() + val result = service.downloadLanguageModel("es") + assertIs(result) + } + + @Test + fun `fake translator returns success with translated content`() = runTest { + val service = FakeDocTranslator(translatedPrefix = "[ES] ") + val result = service.translatePage("onboarding", "# Hello", "es") + assertIs(result) + assertEquals("[ES] # Hello", result.translatedMarkdown) + } + + @Test + fun `fake translator returns ModelDownloadRequired when not downloaded`() = runTest { + val service = FakeDocTranslator(isDownloaded = false) + val result = service.translatePage("onboarding", "# Hello", "es") + assertIs(result) + } + + @Test + fun `fake translator returns Unavailable for unsupported locales`() = runTest { + val service = FakeDocTranslator(supportedLocales = setOf("es", "fr")) + val result = service.translatePage("onboarding", "# Hello", "xx") + assertIs(result) + } + + @Test + fun `cache hit avoids translation call`() = runTest { + var translateCallCount = 0 + val service = FakeDocTranslator(translatedPrefix = "[ES] ", onTranslate = { translateCallCount++ }) + + // First call — translator is invoked + service.translatePage("page1", "# Hello", "es") + assertEquals(1, translateCallCount) + + // Second call with same inputs — fake has no cache, so it calls again + // (real MlKitDocTranslator has DocTranslationCache that would prevent this) + service.translatePage("page1", "# Hello", "es") + assertEquals(2, translateCallCount) + } + + @Test + fun `TranslationResult sealed hierarchy covers all cases`() { + val success: TranslationResult = TranslationResult.Success("translated") + val download: TranslationResult = TranslationResult.ModelDownloadRequired("es", 30) + val unavailable: TranslationResult = TranslationResult.Unavailable + + assertIs(success) + assertIs(download) + assertIs(unavailable) + assertEquals("es", (download as TranslationResult.ModelDownloadRequired).locale) + } +} + +/** Configurable fake for testing cascade behavior. */ +private class FakeDocTranslator( + private val translatedPrefix: String = "[TRANSLATED] ", + private val isDownloaded: Boolean = true, + private val supportedLocales: Set = setOf("es", "fr", "de", "zh", "ja", "ko"), + private val onTranslate: () -> Unit = {}, +) : DocTranslationService { + + override suspend fun translatePage(pageId: String, markdown: String, targetLocale: String): TranslationResult { + if (targetLocale !in supportedLocales) return TranslationResult.Unavailable + if (!isDownloaded) return TranslationResult.ModelDownloadRequired(targetLocale, 30) + onTranslate() + return TranslationResult.Success("$translatedPrefix$markdown") + } + + override suspend fun isLanguageAvailable(locale: String): Boolean = locale in supportedLocales && isDownloaded + + override suspend fun downloadLanguageModel(locale: String): DownloadResult = + if (locale in supportedLocales) DownloadResult.Success else DownloadResult.Failed("Unsupported: $locale") +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreenTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreenTest.kt new file mode 100644 index 000000000..716f568e6 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreenTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocSection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class DocsBrowserScreenTest { + + private fun samplePage(id: String = "connections", title: String = "Connections") = DocPage( + id = id, + title = title, + section = DocSection.UserGuide, + navOrder = 1, + resourcePath = "user/$id.md", + keywords = listOf("connect"), + charCount = 1000, + ) + + @Test + fun loadingState_showsSpinner() = runComposeUiTest { + setContent { + DocsBrowserScreen( + pages = emptyList(), + isLoading = true, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } + onNodeWithText("Loading documentation...").assertIsDisplayed() + } + + @Test + fun emptyPages_noSearchQuery_showsNoDocsMessage() = runComposeUiTest { + setContent { + DocsBrowserScreen( + pages = emptyList(), + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } + onNodeWithText("No documentation available").assertIsDisplayed() + } + + @Test + fun emptyPages_withSearchQuery_showsNoResultsMessage() = runComposeUiTest { + setContent { + DocsBrowserScreen( + pages = emptyList(), + isLoading = false, + searchQuery = "xyzzy", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } + onNodeWithText("No results found").assertIsDisplayed() + } + + @Test + fun pagesLoaded_showsTitles() = runComposeUiTest { + setContent { + DocsBrowserScreen( + pages = listOf(samplePage(), samplePage(id = "mqtt", title = "MQTT")), + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } + onNodeWithText("Connections").assertIsDisplayed() + onNodeWithText("MQTT").assertIsDisplayed() + } + + @Test + fun pageItemClick_callsOnSelectPage() = runComposeUiTest { + var selectedId: String? = null + setContent { + DocsBrowserScreen( + pages = listOf(samplePage()), + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = { selectedId = it }, + onBack = {}, + ) + } + onNodeWithContentDescription("Open Connections").performClick() + runOnIdle { assertEquals("connections", selectedId) } + } + + @Test + fun backButton_callsOnBack() = runComposeUiTest { + var backCalled = false + setContent { + DocsBrowserScreen( + pages = listOf(samplePage()), + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = { backCalled = true }, + ) + } + onNodeWithContentDescription("Navigate back").performClick() + runOnIdle { assertTrue(backCalled) } + } + + @Test + fun userGuideSection_showsSectionHeader() = runComposeUiTest { + setContent { + DocsBrowserScreen( + pages = listOf(samplePage()), + isLoading = false, + searchQuery = "", + onSearchQueryChange = {}, + onSelectPage = {}, + onBack = {}, + ) + } + onNodeWithText("User Guide").assertIsDisplayed() + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsLinkUriHandlerTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsLinkUriHandlerTest.kt new file mode 100644 index 000000000..c743122f8 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsLinkUriHandlerTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.ui.platform.UriHandler +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DocsLinkUriHandlerTest { + + private val navigatedPages = mutableListOf() + private val externalUris = mutableListOf() + + private val fallback = + object : UriHandler { + override fun openUri(uri: String) { + externalUris += uri + } + } + + private val handler = DocsLinkUriHandler(onNavigateToPage = { navigatedPages += it }, fallback = fallback) + + @Test + fun httpLink_delegatesToFallback() { + handler.openUri("https://meshtastic.org/docs/faq") + assertTrue(externalUris.contains("https://meshtastic.org/docs/faq")) + assertTrue(navigatedPages.isEmpty()) + } + + @Test + fun httpLink_delegatesToFallbackForHttp() { + handler.openUri("http://example.com") + assertTrue(externalUris.contains("http://example.com")) + assertTrue(navigatedPages.isEmpty()) + } + + @Test + fun anchorOnlyLink_isIgnored() { + handler.openUri("#permissions") + assertTrue(navigatedPages.isEmpty()) + assertTrue(externalUris.isEmpty()) + } + + @Test + fun simpleName_navigatesToPage() { + handler.openUri("connections") + assertEquals(listOf("connections"), navigatedPages) + } + + @Test + fun relativePathWithParent_extractsFilename() { + handler.openUri("../developer/architecture") + assertEquals(listOf("architecture"), navigatedPages) + } + + @Test + fun htmlExtension_isStripped() { + handler.openUri("mqtt.html") + assertEquals(listOf("mqtt"), navigatedPages) + } + + @Test + fun mdExtension_isStripped() { + handler.openUri("settings-radio-user.md") + assertEquals(listOf("settings-radio-user"), navigatedPages) + } + + @Test + fun linkWithAnchor_stripsAnchorAndNavigates() { + handler.openUri("nodes#signal-quality") + assertEquals(listOf("nodes"), navigatedPages) + } + + @Test + fun relativePathWithHtmlAndAnchor_extractsCleanPageId() { + handler.openUri("../user/mqtt.html#encryption") + assertEquals(listOf("mqtt"), navigatedPages) + } + + @Test + fun blankUri_isIgnored() { + handler.openUri("") + assertTrue(navigatedPages.isEmpty()) + assertTrue(externalUris.isEmpty()) + } + + @Test + fun anchorOnly_emptyAnchor_isIgnored() { + handler.openUri("#") + assertTrue(navigatedPages.isEmpty()) + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreenTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreenTest.kt new file mode 100644 index 000000000..c3cae964a --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreenTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.feature.docs.model.DocPage +import org.meshtastic.feature.docs.model.DocPageContent +import org.meshtastic.feature.docs.model.DocSection +import kotlin.test.Test +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class DocsPageRouteScreenTest { + + private fun samplePage(id: String = "connections", title: String = "Connections") = DocPage( + id = id, + title = title, + section = DocSection.UserGuide, + navOrder = 1, + resourcePath = "user/$id.md", + keywords = listOf("connect"), + charCount = 1000, + ) + + private fun sampleContent(page: DocPage = samplePage(), markdown: String? = "# Hello\n\nSome content here.") = + DocPageContent(page = page, markdown = markdown) + + @Test + fun loadingState_showsSpinner() = runComposeUiTest { + setContent { DocsPageRouteScreen(pageId = "connections", content = null, isLoading = true, onBack = {}) } + // Loading state shows spinner, not "Page not found" + onNodeWithText("Page not found: connections").assertDoesNotExist() + } + + @Test + fun contentNull_notLoading_showsPageNotFound() = runComposeUiTest { + setContent { DocsPageRouteScreen(pageId = "nonexistent", content = null, isLoading = false, onBack = {}) } + onNodeWithText("Page not found: nonexistent").assertIsDisplayed() + onNodeWithText("This page may have been moved or removed.").assertIsDisplayed() + } + + @Test + fun contentLoaded_showsPageTitleInAppBar() = runComposeUiTest { + setContent { + DocsPageRouteScreen( + pageId = "connections", + content = sampleContent(page = samplePage(title = "Connections")), + isLoading = false, + onBack = {}, + ) + } + onNodeWithText("Connections").assertIsDisplayed() + } + + @Test + fun contentLoaded_doesNotShowPageNotFound() = runComposeUiTest { + setContent { + DocsPageRouteScreen( + pageId = "connections", + content = sampleContent(markdown = "# Getting Started\n\nWelcome."), + isLoading = false, + onBack = {}, + ) + } + // Verify content branch — page not found message should NOT be visible + onNodeWithText("Page not found: connections").assertDoesNotExist() + } + + @Test + fun backButton_callsOnBack() = runComposeUiTest { + var backCalled = false + setContent { + DocsPageRouteScreen( + pageId = "connections", + content = sampleContent(), + isLoading = false, + onBack = { backCalled = true }, + ) + } + onNodeWithContentDescription("Navigate back").performClick() + runOnIdle { assertTrue(backCalled) } + } + + @Test + fun nullMarkdown_showsFallbackWithoutCrash() = runComposeUiTest { + setContent { + DocsPageRouteScreen( + pageId = "connections", + content = sampleContent(markdown = null), + isLoading = false, + onBack = {}, + ) + } + // Content renders without crash; page not found should NOT appear since content object exists + onNodeWithText("Page not found: connections").assertDoesNotExist() + } +} diff --git a/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBarTest.kt b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBarTest.kt new file mode 100644 index 000000000..dcf4ee705 --- /dev/null +++ b/feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBarTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.docs.ui + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.v2.runComposeUiTest +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalTestApi::class) +class DocsSearchBarTest { + + @Test + fun emptyQuery_clearButtonNotShown() = runComposeUiTest { + setContent { DocsSearchBar(query = "", onQueryChange = {}) } + onNodeWithContentDescription("Clear search").assertDoesNotExist() + } + + @Test + fun nonEmptyQuery_clearButtonShown() = runComposeUiTest { + setContent { DocsSearchBar(query = "bluetooth", onQueryChange = {}) } + onNodeWithContentDescription("Clear search").assertIsDisplayed() + } + + @Test + fun clearButtonClick_callsOnQueryChangeWithEmpty() = runComposeUiTest { + var received: String? = null + setContent { DocsSearchBar(query = "bluetooth", onQueryChange = { received = it }) } + onNodeWithContentDescription("Clear search").performClick() + runOnIdle { assertEquals("", received) } + } + + @Test + fun textInput_callsOnQueryChange() = runComposeUiTest { + val queries = mutableListOf() + setContent { DocsSearchBar(query = "", onQueryChange = { queries += it }) } + // OutlinedTextField placeholder is not findable by text; use semantics matcher + onNode(hasSetTextAction()).performTextInput("mesh") + runOnIdle { assertEquals("mesh", queries.last()) } + } +} diff --git a/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsPreviews.kt b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsPreviews.kt new file mode 100644 index 000000000..386f8c2e7 --- /dev/null +++ b/feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/component/MapControlsPreviews.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.map.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import org.meshtastic.core.ui.theme.AppTheme + +@PreviewLightDark +@Composable +@Suppress("PreviewPublic") +fun MapControlsOverlayPreview() { + AppTheme { + Box( + modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface).padding(16.dp), + contentAlignment = Alignment.Center, + ) { + MapControlsOverlay( + onToggleFilterMenu = {}, + bearing = 45f, + onCompassClick = {}, + isLocationTrackingEnabled = false, + onToggleLocationTracking = {}, + showRefresh = true, + isRefreshing = false, + onRefresh = {}, + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt index 432bbe1c3..34823ca57 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailPreviews.kt @@ -104,7 +104,8 @@ fun NodeDetailContentLoadingPreview() { @PreviewLightDark @Composable -private fun NodeDetailContentMinimalPreview() { +@Suppress("PreviewPublic") +fun NodeDetailContentMinimalPreview() { val node = previewData.minnieMouse AppTheme { Surface { diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 99e86e905..05a4c05a4 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -47,6 +47,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.export_configuration import org.meshtastic.core.resources.filter_settings +import org.meshtastic.core.resources.help_and_documentation import org.meshtastic.core.resources.import_configuration import org.meshtastic.core.resources.preferences_language import org.meshtastic.core.resources.remotely_administrating @@ -55,6 +56,7 @@ import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.icon.FilterList +import org.meshtastic.core.ui.icon.HelpOutline import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.core.ui.icon.Wifi import org.meshtastic.feature.settings.component.AppInfoSection @@ -268,6 +270,15 @@ fun SettingsScreen( onNavigateToAbout = { onNavigate(SettingsRoute.About) }, ) } + + ExpressiveSection(title = stringResource(Res.string.help_and_documentation)) { + ListItem( + text = stringResource(Res.string.help_and_documentation), + leadingIcon = MeshtasticIcons.HelpOutline, + ) { + onNavigate(SettingsRoute.HelpDocs) + } + } } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/SettingsSectionPreviews.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/SettingsSectionPreviews.kt new file mode 100644 index 000000000..af9b7b57b --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/component/SettingsSectionPreviews.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings.component + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import org.meshtastic.core.ui.theme.AppTheme + +@PreviewLightDark +@Composable +@Suppress("PreviewPublic") +fun NotificationSectionPreview() { + AppTheme { + Surface { + NotificationSection( + messagesEnabled = true, + onToggleMessages = {}, + nodeEventsEnabled = true, + onToggleNodeEvents = {}, + lowBatteryEnabled = false, + onToggleLowBattery = {}, + ) + } + } +} diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 06ad3df7a..31ab16a16 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary +import org.meshtastic.core.resources.help_and_documentation import org.meshtastic.core.resources.info import org.meshtastic.core.resources.modules_already_unlocked import org.meshtastic.core.resources.modules_unlocked @@ -61,6 +62,7 @@ import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.icon.ChevronRight import org.meshtastic.core.ui.icon.FormatPaint +import org.meshtastic.core.ui.icon.HelpOutline import org.meshtastic.core.ui.icon.Info import org.meshtastic.core.ui.icon.Language import org.meshtastic.core.ui.icon.Memory @@ -215,6 +217,15 @@ fun DesktopSettingsScreen( onToggleLowBattery = { settingsViewModel.setLowBatteryEnabled(it) }, ) + ExpressiveSection(title = stringResource(Res.string.help_and_documentation)) { + ListItem( + text = stringResource(Res.string.help_and_documentation), + leadingIcon = MeshtasticIcons.HelpOutline, + ) { + onNavigate(SettingsRoute.HelpDocs) + } + } + DesktopAppInfoSection( appVersionName = settingsViewModel.appVersionName, excludedModulesUnlocked = excludedModulesUnlocked, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5241cce5c..8c9772ab9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ maps-compose = "8.3.0" # ML Kit mlkit-barcode-scanning = "17.3.0" +mlkit-translate = "17.0.3" # CameraX camerax = "1.6.1" @@ -155,6 +156,8 @@ jetbrains-compose-material3-adaptive-navigation3 = { module = "org.jetbrains.com jetbrains-compose-material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "compose-multiplatform-material3" } # Google +firebase-ai = { module = "com.google.firebase:firebase-ai" } +firebase-ai-ondevice = { module = "com.google.firebase:firebase-ai-ondevice", version = "16.0.0-beta02" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } firebase-bom = { module = "com.google.firebase:firebase-bom", version = "34.13.0" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics" } @@ -169,6 +172,7 @@ maps-compose = { module = "com.google.maps.android:maps-compose", version.ref = maps-compose-utils = { module = "com.google.maps.android:maps-compose-utils", version.ref = "maps-compose" } maps-compose-widgets = { module = "com.google.maps.android:maps-compose-widgets", version.ref = "maps-compose" } mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkit-barcode-scanning" } +mlkit-translate = { module = "com.google.mlkit:translate", version.ref = "mlkit-translate" } play-services-maps = { module = "com.google.android.gms:play-services-maps", version = "20.0.0" } wire-runtime = { module = "com.squareup.wire:wire-runtime", version.ref = "wire" } zxing-core = { module = "com.google.zxing:core", version = "3.5.4" } diff --git a/screenshot-tests/build.gradle.kts b/screenshot-tests/build.gradle.kts index c6198a8ea..964b3891c 100644 --- a/screenshot-tests/build.gradle.kts +++ b/screenshot-tests/build.gradle.kts @@ -50,6 +50,8 @@ dependencies { implementation(project(":feature:settings")) implementation(project(":feature:firmware")) implementation(project(":feature:intro")) + implementation(project(":feature:map")) + implementation(project(":feature:docs")) implementation(libs.compose.multiplatform.foundation) implementation(libs.compose.multiplatform.material3) @@ -65,14 +67,42 @@ tasks.register("copyDocsScreenshots") { val referenceDir = layout.projectDirectory.dir("src/screenshotTestDebug/reference") val manifestFile = layout.projectDirectory.file("docs-screenshots-manifest.txt") + val aliasFile = layout.projectDirectory.file("docs-screenshot-aliases.properties") val outputDir = rootProject.layout.projectDirectory.dir("docs/screenshots") - from(referenceDir) + // Read manifest patterns at configuration time so Copy task can resolve includes + val manifestPatterns = + manifestFile.asFile.let { file -> + if (file.exists()) { + file.readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } + } else { + emptyList() + } + } + + from(referenceDir) { include(manifestPatterns) } into(outputDir) - // Flatten directory structure: just the filename - eachFile { path = name } - duplicatesStrategy = DuplicatesStrategy.FAIL + // Build reverse alias map (CST name → semantic name) for renaming during copy. + val reverseAliases: Map by lazy { + val file = aliasFile.asFile + if (!file.exists()) return@lazy emptyMap() + file + .readLines() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") && it.contains('=') } + .associate { line -> + val (semantic, cst) = line.split('=', limit = 2) + cst.trim() to semantic.trim() + } + } + + // Flatten directory structure and apply alias renaming + eachFile { + val alias = reverseAliases[name] + path = alias ?: name + } + duplicatesStrategy = DuplicatesStrategy.WARN includeEmptyDirs = false doFirst { @@ -81,25 +111,20 @@ tasks.register("copyDocsScreenshots") { "Reference screenshot directory not found: ${refDir.absolutePath}. " + "Run :screenshot-tests:updateDebugScreenshotTest first." } - val file = manifestFile.asFile - require(file.exists()) { - "Screenshot manifest not found: ${file.absolutePath}. " + - "This file lists which reference screenshots to copy for the docs pipeline." + if (manifestPatterns.isEmpty()) { + logger.warn("Screenshot manifest is empty — no files will be copied.") } - val patterns = file.readLines().map { it.trim() }.filter { it.isNotEmpty() && !it.startsWith("#") } - if (patterns.isEmpty()) { - logger.warn("Screenshot manifest is empty — no files will be copied: ${file.absolutePath}") - } - include(patterns) } doLast { - val copied = outputs.files.files.filter { it.isFile } - if (copied.isEmpty()) { + val copiedFiles = outputDir.asFile.listFiles()?.filter { it.isFile && it.extension == "png" } ?: emptyList() + if (copiedFiles.isEmpty()) { logger.warn( "copyDocsScreenshots: manifest patterns matched no files in ${referenceDir.asFile.absolutePath}. " + "Check pattern spelling in ${manifestFile.asFile.name}.", ) + } else { + logger.lifecycle("copyDocsScreenshots: copied ${copiedFiles.size} screenshots to ${outputDir.asFile.path}") } } } diff --git a/screenshot-tests/docs-screenshot-aliases.properties b/screenshot-tests/docs-screenshot-aliases.properties new file mode 100644 index 000000000..bf1353a9a --- /dev/null +++ b/screenshot-tests/docs-screenshot-aliases.properties @@ -0,0 +1,65 @@ +# Screenshot alias mapping: semantic_name=CST_reference_filename +# Used by copyDocsScreenshots to rename CST-generated screenshots to the +# human-readable names referenced in docs/user/**/*.md and docs/developer/**/*.md. +# +# Format: docs_name=cst_reference_filename (both without directory prefix) +# Lines starting with # are comments. Blank lines are ignored. + +# Onboarding +onboarding_welcome.png=ScreenshotWelcomeScreen_Light_b29dc7a7_0.png + +# Connections +connections_bluetooth_scan.png=ScreenshotScanningBle_Light_b29dc7a7_0.png +connections_transport_filters.png=ScreenshotTransportFilterChips_Light_b29dc7a7_0.png +connections_connecting.png=ScreenshotConnectingDeviceInfo_Light_b29dc7a7_0.png +connections_empty_state.png=ScreenshotEmptyStateContent_Light_b29dc7a7_0.png + +# Firmware +firmware_checking.png=ScreenshotFirmwareChecking_Light_b29dc7a7_0.png +firmware_disclaimer.png=ScreenshotFirmwareDisclaimer_Light_b29dc7a7_0.png +firmware_success.png=ScreenshotFirmwareSuccess_Light_b29dc7a7_0.png + +# Messages +messages_quick_chat.png=ScreenshotChannelInfo_Light_b29dc7a7_0.png +messages-and-channels_channel_list.png=ScreenshotChannelItem_Light_b29dc7a7_0.png + +# Nodes +nodes_node_list.png=ScreenshotNodeChip_Light_b29dc7a7_0.png +nodes_detail_section.png=ScreenshotAppInfoSection_Light_b29dc7a7_0.png +nodes_detail_local.png=ScreenshotDeviceListItem_Light_b29dc7a7_0.png +nodes_position.png=ScreenshotSatelliteCountInfo_Light_b29dc7a7_0.png +nodes_signal_info.png=ScreenshotSignalInfoSimple_Light_b29dc7a7_0.png +nodes_battery_info.png=ScreenshotMaterialBatteryInfo_Light_b29dc7a7_0.png +nodes_hops_info.png=ScreenshotHopsInfo_Light_b29dc7a7_0.png +nodes_last_heard.png=ScreenshotLastHeardInfo_Light_b29dc7a7_0.png +nodes_distance_info.png=ScreenshotDistanceInfo_Light_b29dc7a7_0.png + +# Node metrics +node-metrics_telemetric_actions.png=ScreenshotElevationInfo_Light_b29dc7a7_0.png + +# Settings +settings-radio-user_lora_config.png=ScreenshotDropDownPreference_Light_b29dc7a7_0.png +settings_dropdown.png=ScreenshotDropDownPreference_Light_b29dc7a7_0.png +settings_slider.png=ScreenshotSliderPreference_Light_b29dc7a7_0.png +settings_switch.png=ScreenshotSwitchPreference_Light_b29dc7a7_0.png +settings_notifications.png=ScreenshotNotificationSection_Light_b29dc7a7_0.png + +# Map +map_controls_overlay.png=ScreenshotMapControlsOverlay_Light_b29dc7a7_0.png + +# Module config UI elements +settings_titled_card.png=ScreenshotTitledCard_Light_b29dc7a7_0.png +settings_password_field.png=ScreenshotEditPasswordPreference_Light_b29dc7a7_0.png +settings_text_field.png=ScreenshotEditTextPreference_Light_b29dc7a7_0.png +settings_ipv4_field.png=ScreenshotEditIPv4Preference_Light_b29dc7a7_0.png +settings_appearance.png=ScreenshotAppearanceSection_Light_b29dc7a7_0.png + +# Messaging (conversation) +messages_message_items.png=ScreenshotMessageItem_Light_b29dc7a7_0.png +messages_reactions.png=ScreenshotReactionRow_Light_b29dc7a7_0.png + +# Node detail (minimal / managed) +nodes_detail_minimal.png=ScreenshotNodeDetailContentMinimal_Light_b29dc7a7_0.png +nodes_device_metrics_card.png=ScreenshotDeviceMetricsCard_Light_b29dc7a7_0.png +nodes_environment_metrics.png=ScreenshotEnvironmentMetricsContent_Light_b29dc7a7_0.png + diff --git a/screenshot-tests/docs-screenshots-manifest.txt b/screenshot-tests/docs-screenshots-manifest.txt index 1e9df32bf..f97fdf661 100644 --- a/screenshot-tests/docs-screenshots-manifest.txt +++ b/screenshot-tests/docs-screenshots-manifest.txt @@ -25,3 +25,16 @@ # Feature: WiFi Provision **/WifiProvisionScreenshotTestsKt/Screenshot*_Light_*.png + +# Feature: Docs +**/DocsScreenshotTestsKt/Screenshot*_Light_*.png + +# Feature: Map +**/MapScreenshotTestsKt/Screenshot*_Light_*.png + +# Feature: Messaging +**/MessagingScreenshotTestsKt/Screenshot*_Light_*.png + +# Feature: Nodes +**/NodeScreenshotTestsKt/Screenshot*_Light_*.png + diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/DocsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/DocsScreenshotTests.kt new file mode 100644 index 000000000..8b44743a9 --- /dev/null +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/DocsScreenshotTests.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.screenshots.feature + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.tools.screenshot.PreviewTest +import org.meshtastic.feature.docs.ui.ChirpyAssistantContentPreview +import org.meshtastic.feature.docs.ui.ChirpyAssistantLoadingPreview +import org.meshtastic.feature.docs.ui.DocsBrowserScreenEmptyPreview +import org.meshtastic.feature.docs.ui.DocsBrowserScreenPreview +import org.meshtastic.feature.docs.ui.DocsPageContentPreview +import org.meshtastic.feature.docs.ui.DocsPageNotFoundPreview +import org.meshtastic.feature.docs.ui.DocsSearchBarEmptyPreview +import org.meshtastic.feature.docs.ui.DocsSearchBarWithQueryPreview + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsBrowser() { + DocsBrowserScreenPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsBrowserEmpty() { + DocsBrowserScreenEmptyPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsPageContent() { + DocsPageContentPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsPageNotFound() { + DocsPageNotFoundPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotChirpyAssistant() { + ChirpyAssistantContentPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotChirpyAssistantLoading() { + ChirpyAssistantLoadingPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsSearchBarEmpty() { + DocsSearchBarEmptyPreview() +} + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotDocsSearchBarWithQuery() { + DocsSearchBarWithQueryPreview() +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt new file mode 100644 index 000000000..aa084f4b5 --- /dev/null +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/MapScreenshotTests.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.screenshots.feature + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.android.tools.screenshot.PreviewTest +import org.meshtastic.feature.map.component.MapControlsOverlayPreview + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotMapControlsOverlay() { + MapControlsOverlayPreview() +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt index 0074f9192..e2c763f89 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/NodeScreenshotTests.kt @@ -27,6 +27,7 @@ import org.meshtastic.feature.node.component.TelemetricActionsSectionEmptyPrevie import org.meshtastic.feature.node.component.TelemetricActionsSectionPreview import org.meshtastic.feature.node.detail.NodeDetailContentLoadingPreview import org.meshtastic.feature.node.detail.NodeDetailContentLocalPreview +import org.meshtastic.feature.node.detail.NodeDetailContentMinimalPreview import org.meshtastic.feature.node.detail.NodeDetailContentRemotePreview import org.meshtastic.feature.node.metrics.DeviceMetricsCardPreview import org.meshtastic.feature.node.metrics.LegendPreview @@ -115,3 +116,10 @@ fun ScreenshotDeviceMetricsCard() { fun ScreenshotEnvironmentMetricsContent() { PreviewEnvironmentMetricsContent() } + +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotNodeDetailContentMinimal() { + NodeDetailContentMinimalPreview() +} diff --git a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt index b1e30c3e9..def6bc7c7 100644 --- a/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt +++ b/screenshot-tests/src/screenshotTest/kotlin/org/meshtastic/screenshots/feature/SettingsScreenshotTests.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.tooling.preview.PreviewLightDark import com.android.tools.screenshot.PreviewTest import org.meshtastic.feature.settings.component.AppInfoSectionPreview import org.meshtastic.feature.settings.component.AppearanceSectionPreview +import org.meshtastic.feature.settings.component.NotificationSectionPreview import org.meshtastic.feature.settings.component.PersistenceSectionPreview import org.meshtastic.feature.settings.radio.component.TakConfigCardPreview import org.meshtastic.feature.settings.radio.component.TakServerSectionDisabledPreview @@ -50,6 +51,13 @@ fun ScreenshotAppInfoSection() { AppInfoSectionPreview() } +@PreviewTest +@PreviewLightDark +@Composable +fun ScreenshotNotificationSection() { + NotificationSectionPreview() +} + @PreviewTest @PreviewLightDark @Composable diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..bc10660c0 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Light_b29dc7a7_0.png new file mode 100644 index 000000000..a61304754 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistantLoading_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..e6cc038b5 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Light_b29dc7a7_0.png new file mode 100644 index 000000000..2f2f78fa4 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotChirpyAssistant_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..a073c81f9 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Light_b29dc7a7_0.png new file mode 100644 index 000000000..eeac8e08b Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowserEmpty_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..90911e10b Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Light_b29dc7a7_0.png new file mode 100644 index 000000000..857fc0fcf Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsBrowser_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..ea4267e6f Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Light_b29dc7a7_0.png new file mode 100644 index 000000000..d19940d51 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageContent_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..39737f474 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Light_b29dc7a7_0.png new file mode 100644 index 000000000..cc7915d31 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsPageNotFound_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..ccdb1a515 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Light_b29dc7a7_0.png new file mode 100644 index 000000000..91dc4e2c4 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarEmpty_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..607267e08 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Light_b29dc7a7_0.png new file mode 100644 index 000000000..77b732335 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/DocsScreenshotTestsKt/ScreenshotDocsSearchBarWithQuery_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..2ee8f7453 Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Light_b29dc7a7_0.png new file mode 100644 index 000000000..4a7ac640e Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/MapScreenshotTestsKt/ScreenshotMapControlsOverlay_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..68adb57cc Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Light_b29dc7a7_0.png new file mode 100644 index 000000000..fc5a4913a Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/NodeScreenshotTestsKt/ScreenshotNodeDetailContentMinimal_Light_b29dc7a7_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Dark_d19fbf1f_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Dark_d19fbf1f_0.png new file mode 100644 index 000000000..dc7d0942d Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Dark_d19fbf1f_0.png differ diff --git a/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Light_b29dc7a7_0.png b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Light_b29dc7a7_0.png new file mode 100644 index 000000000..a18dcf00b Binary files /dev/null and b/screenshot-tests/src/screenshotTestDebug/reference/org/meshtastic/screenshots/feature/SettingsScreenshotTestsKt/ScreenshotNotificationSection_Light_b29dc7a7_0.png differ diff --git a/scripts/check-doc-coverage.js b/scripts/check-doc-coverage.js new file mode 100644 index 000000000..7bebbaad7 --- /dev/null +++ b/scripts/check-doc-coverage.js @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// scripts/check-doc-coverage.js +// Checks that each user-facing feature module has corresponding documentation. +// Exit 0 = full coverage, Exit 1 = gaps found. +// +// Usage: node scripts/check-doc-coverage.js [repo-root] + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { forEachDocPage } = require("./lib/frontmatter"); + +const REPO_ROOT = path.resolve(process.argv[2] || "."); +const DOCS_DIR = path.join(REPO_ROOT, "docs"); + +// Map of feature module directory names to expected doc page slugs. +// Modules not listed here are considered internal (no user-facing docs required). +const MODULE_TO_DOCS = { + "feature/connections": { pages: ["connections"], section: "user" }, + "feature/discovery": { pages: ["discovery"], section: "user" }, + "feature/docs": { pages: [], section: "user", internal: true }, + "feature/firmware": { pages: ["firmware"], section: "user" }, + "feature/intro": { pages: ["onboarding"], section: "user" }, + "feature/map": { pages: ["map-and-waypoints"], section: "user" }, + "feature/messaging": { pages: ["messages-and-channels"], section: "user" }, + "feature/node": { pages: ["nodes", "node-metrics"], section: "user" }, + "feature/settings": { pages: ["settings-radio-user", "settings-module-admin"], section: "user" }, + "feature/telemetry": { pages: ["telemetry-and-sensors"], section: "user" }, +}; + +// Collect existing doc pages +const existingPages = new Set(); +forEachDocPage(DOCS_DIR, (_filePath, slug, section) => { + existingPages.add(`${section}/${slug}`); +}); + +console.log(`Checking doc coverage for ${Object.keys(MODULE_TO_DOCS).length} feature modules...`); +console.log(`Found ${existingPages.size} doc pages.`); +console.log(""); + +let gaps = 0; + +for (const [module, config] of Object.entries(MODULE_TO_DOCS)) { + if (config.internal) continue; + + const moduleDir = path.join(REPO_ROOT, module); + if (!fs.existsSync(moduleDir)) continue; + + for (const page of config.pages) { + const key = `${config.section}/${page}`; + if (!existingPages.has(key)) { + console.log(` ✗ ${module} → missing ${key}.md`); + gaps++; + } + } +} + +// Also check for doc pages that reference non-existent modules (orphans) +const documentedModules = new Set(); +for (const config of Object.values(MODULE_TO_DOCS)) { + for (const page of config.pages) { + documentedModules.add(`${config.section}/${page}`); + } +} + +// Report coverage summary +const coveredModules = Object.entries(MODULE_TO_DOCS) + .filter(([, c]) => !c.internal) + .filter(([m]) => fs.existsSync(path.join(REPO_ROOT, m))); +const totalExpected = coveredModules.reduce((sum, [, c]) => sum + c.pages.length, 0); +const covered = totalExpected - gaps; +const pct = totalExpected > 0 ? Math.round((covered / totalExpected) * 100) : 100; + +console.log(""); +console.log(`Coverage: ${covered}/${totalExpected} required pages present (${pct}%)`); + +if (gaps > 0) { + console.log(`\n${gaps} documentation gap(s) found.`); + process.exit(1); +} else { + console.log("All feature modules have documentation coverage."); + process.exit(0); +} diff --git a/scripts/check-doc-freshness.js b/scripts/check-doc-freshness.js new file mode 100644 index 000000000..a73c6261a --- /dev/null +++ b/scripts/check-doc-freshness.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// scripts/check-doc-freshness.js +// Reports doc pages whose last_updated frontmatter is older than a threshold. +// Exit 0 = all fresh, Exit 1 = stale pages found (advisory). +// +// Usage: node scripts/check-doc-freshness.js [docs-dir] [--max-age-days=180] + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { parseFrontmatter, forEachDocPage } = require("./lib/frontmatter"); + +const args = process.argv.slice(2); +const positional = args.filter(a => !a.startsWith("--")); +const DOCS_DIR = path.resolve(positional[0] || "docs"); + +const maxAgeArg = args.find(a => a.startsWith("--max-age-days=")); +const MAX_AGE_DAYS = maxAgeArg ? parseInt(maxAgeArg.split("=")[1], 10) : 180; + +const now = new Date(); +let staleCount = 0; +let totalCount = 0; + +console.log(`Checking doc freshness (max age: ${MAX_AGE_DAYS} days)...`); +console.log(""); + +forEachDocPage(DOCS_DIR, (filePath, slug, section) => { + totalCount++; + const content = fs.readFileSync(filePath, "utf-8"); + const { fields } = parseFrontmatter(content); + + if (!fields.last_updated) { + console.log(` ⚠ ${section}/${slug}.md — missing last_updated field`); + staleCount++; + return; + } + + const lastUpdated = new Date(fields.last_updated); + const ageDays = Math.floor((now - lastUpdated) / (1000 * 60 * 60 * 24)); + + if (ageDays > MAX_AGE_DAYS) { + console.log(` ⚠ ${section}/${slug}.md — ${ageDays} days old (last: ${fields.last_updated})`); + staleCount++; + } +}); + +console.log(""); +if (staleCount > 0) { + console.log(`${staleCount}/${totalCount} page(s) need review (older than ${MAX_AGE_DAYS} days or missing date).`); + process.exit(1); +} else { + console.log(`All ${totalCount} pages are fresh (updated within ${MAX_AGE_DAYS} days).`); + process.exit(0); +} diff --git a/scripts/lib/frontmatter.js b/scripts/lib/frontmatter.js new file mode 100644 index 000000000..3d39e52cf --- /dev/null +++ b/scripts/lib/frontmatter.js @@ -0,0 +1,51 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const FM_RE = /^---\n([\s\S]*?)\n---\n/; + +/** + * Parse YAML-ish frontmatter from a markdown string. + * Returns { fields: { key: string }, body: string, raw: string }. + * `fields` maps lowercase keys to their raw string values (no YAML arrays). + */ +function parseFrontmatter(content) { + const match = content.match(FM_RE); + if (!match) return { fields: {}, body: content, raw: "" }; + + const raw = match[1]; + const body = content.slice(match[0].length); + const fields = {}; + + for (const line of raw.split("\n")) { + const kv = line.match(/^(\w[\w_-]*):\s*(.*)/); + if (kv) fields[kv[1]] = kv[2].trim(); + } + + return { fields, body, raw }; +} + +/** Discover all .md page slugs under docs/{section}/ */ +function discoverSlugs(docsDir, section) { + const dir = path.join(docsDir, section); + if (!fs.existsSync(dir)) return new Set(); + return new Set( + fs.readdirSync(dir) + .filter(f => f.endsWith(".md")) + .map(f => f.replace(/\.md$/, "")), + ); +} + +/** Iterate all doc pages, calling fn(filePath, slug, section) */ +function forEachDocPage(docsDir, fn) { + for (const section of ["user", "developer"]) { + const dir = path.join(docsDir, section); + if (!fs.existsSync(dir)) continue; + for (const file of fs.readdirSync(dir).filter(f => f.endsWith(".md")).sort()) { + fn(path.join(dir, file), file.replace(/\.md$/, ""), section); + } + } +} + +module.exports = { parseFrontmatter, discoverSlugs, forEachDocPage }; diff --git a/scripts/sync-android-docs.js b/scripts/sync-android-docs.js new file mode 100755 index 000000000..b555f87db --- /dev/null +++ b/scripts/sync-android-docs.js @@ -0,0 +1,331 @@ +#!/usr/bin/env node +// scripts/sync-android-docs.js +// Transforms Android in-app docs for publishing on the meshtastic.org Docusaurus site. +// +// Usage: node scripts/sync-android-docs.js [--convert-webp] [--dry-run] +// +// Path to a clone of meshtastic/Meshtastic-Android (or omit to +// auto-detect from this script's location in the repo). +// --convert-webp Convert PNG/JPG/JPEG/GIF images to WebP via cwebp and rewrite +// all image references in Markdown to use .webp. Requires cwebp on PATH. +// --dry-run Print what would be written without actually writing files. +// +// Output structure (relative to CWD, typically the meshtastic/meshtastic repo root): +// docs/software/android/user/*.md +// docs/software/android/developer/*.md +// docs/software/android/index.md +// static/img/android/docs/*.webp (or .png/.svg if not converting) + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); +const { discoverSlugs } = require("./lib/frontmatter"); + +// ── Configuration ──────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const CONVERT_WEBP = args.includes("--convert-webp"); +const DRY_RUN = args.includes("--dry-run"); +const positionalArgs = args.filter(a => !a.startsWith("--")); + +const WEBP_CONVERTIBLE = new Set([".png", ".jpg", ".jpeg", ".gif"]); +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]); + +// Resolve source: explicit path argument, or auto-detect from script location +const ANDROID_REPO_ROOT = positionalArgs.length > 0 + ? path.resolve(positionalArgs[0]) + : path.resolve(__dirname, ".."); +const SRC_DOCS_DIR = path.join(ANDROID_REPO_ROOT, "docs"); +const SRC_SCREENSHOTS_DIR = path.join(SRC_DOCS_DIR, "assets", "screenshots"); + +if (!fs.existsSync(SRC_DOCS_DIR)) { + console.error(`Error: docs directory not found at ${SRC_DOCS_DIR}`); + console.error("Usage: node sync-android-docs.js [--convert-webp] [--dry-run]"); + process.exit(1); +} + +// Output directories (relative to CWD, which should be the meshtastic/meshtastic repo) +const DEST_DOCS_DIR = path.join("docs", "software", "android"); +const DEST_IMAGES_DIR = path.join("static", "img", "android", "docs"); + +// Derive sibling page slugs from the filesystem (no manual sync needed) +const KNOWN_USER_SLUGS = discoverSlugs(SRC_DOCS_DIR, "user"); +const KNOWN_DEV_SLUGS = discoverSlugs(SRC_DOCS_DIR, "developer"); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function ensureDir(dir) { + if (!DRY_RUN) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function writeFile(filePath, content) { + if (DRY_RUN) { + console.log(`[dry-run] Would write: ${filePath} (${Buffer.byteLength(content)} bytes)`); + } else { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, content, "utf-8"); + console.log(`Wrote: ${filePath}`); + } +} + +function copyFile(src, dest) { + if (DRY_RUN) { + console.log(`[dry-run] Would copy: ${src} → ${dest}`); + } else { + ensureDir(path.dirname(dest)); + fs.copyFileSync(src, dest); + console.log(`Copied: ${dest}`); + } +} + +/** + * Rewrite image references in markdown to point to /img/android/docs/. + * When --convert-webp, convertible extensions become .webp. + */ +function rewriteImagePaths(content) { + function destBasename(imgPath) { + const base = path.basename(imgPath); + const ext = path.extname(base).toLowerCase(); + if (CONVERT_WEBP && WEBP_CONVERTIBLE.has(ext)) { + return base.slice(0, -ext.length) + ".webp"; + } + return base; + } + + return content + .replace( + /!\[([^\]]*)\]\((?!https?:\/\/)(?!\/img\/)([^)]+)\)/g, + (match, alt, imgPath) => { + const ext = path.extname(path.basename(imgPath)).toLowerCase(); + if (!IMAGE_EXTENSIONS.has(ext)) return match; + return `![${alt}](/img/android/docs/${destBasename(imgPath)})`; + }, + ) + .replace( + /]*?)src=["'](?!https?:\/\/)(?!\/img\/)([^"']+)["']([^>]*)>/gi, + (match, before, imgPath, after) => { + const ext = path.extname(path.basename(imgPath)).toLowerCase(); + if (!IMAGE_EXTENSIONS.has(ext)) return match; + return ``; + }, + ); +} + +/** + * Rewrite relative markdown links between sibling pages. + * e.g., `[text](connections)` → `[text](connections.md)` + * e.g., `[text](../developer/testing)` → `[text](../developer/testing.md)` + */ +function rewriteSiblingLinks(content, section) { + const slugs = section === "user" ? KNOWN_USER_SLUGS : KNOWN_DEV_SLUGS; + + // Match [text](link) where link is NOT an absolute URL, NOT an anchor, NOT already .md + return content.replace( + /\[([^\]]*)\]\((?!https?:\/\/)(?!#)([^)]+)\)/g, + (match, text, link) => { + // Skip if already has .md extension or is an image + if (link.endsWith(".md") || IMAGE_EXTENSIONS.has(path.extname(link).toLowerCase())) { + return match; + } + + // Check for cross-section links like ../developer/testing + const crossMatch = link.match(/^\.\.\/(\w+)\/(.+)/); + if (crossMatch) { + const [, targetSection, slug] = crossMatch; + const targetSlugs = targetSection === "user" ? KNOWN_USER_SLUGS : KNOWN_DEV_SLUGS; + if (targetSlugs.has(slug)) { + return `[${text}](../${targetSection}/${slug}.md)`; + } + } + + // Check for sibling links + const bare = link.replace(/^\.\//, ""); + if (slugs.has(bare)) { + return `[${text}](${bare}.md)`; + } + + return match; + }, + ); +} + +/** + * Transform Jekyll/kramdown frontmatter to Docusaurus-compatible format. + * Strips `parent`, `aliases`, and remaps `nav_order` to `sidebar_position`. + */ +function transformFrontmatter(content, section) { + const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n/); + if (!fmMatch) return content; + + const fmBlock = fmMatch[1]; + const body = content.slice(fmMatch[0].length); + + const lines = fmBlock.split("\n"); + const newLines = []; + let sidebarPosition = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip Jekyll-specific fields + if (trimmed.startsWith("parent:")) continue; + if (trimmed.startsWith("aliases:")) continue; + if (trimmed.startsWith("- ")) continue; // alias list items + + // Remap nav_order → sidebar_position + const navMatch = trimmed.match(/^nav_order:\s*(\d+)/); + if (navMatch) { + sidebarPosition = navMatch[1]; + newLines.push(`sidebar_position: ${sidebarPosition}`); + continue; + } + + if (trimmed) newLines.push(line); + } + + // Add parent reference for Docusaurus category + const parentTitle = section === "user" ? "User Guide" : "Developer Guide"; + newLines.push(`parent: ${parentTitle}`); + + return `---\n${newLines.join("\n")}\n---\n${body}`; +} + +/** + * Convert Jekyll-style callouts to Docusaurus admonitions. + * > **Tip — text** → :::tip\ntext\n::: + */ +function convertCallouts(content) { + // Match blockquotes starting with **Tip/Note/Warning — + return content.replace( + /^(> \*\*(Tip|Note|Warning)\s*[—–-]\s*)([^*]*)\*\*\s*([\s\S]*?)(?=\n(?!>)|$)/gm, + (match, prefix, type, title, body) => { + const admonitionType = type.toLowerCase(); + const cleanBody = body.replace(/^>\s?/gm, "").trim(); + const fullContent = title.trim() ? `${title.trim()} ${cleanBody}` : cleanBody; + return `:::${admonitionType}\n${fullContent}\n:::`; + }, + ); +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +function processMarkdown(srcPath, destPath, section) { + let content = fs.readFileSync(srcPath, "utf-8"); + content = transformFrontmatter(content, section); + content = rewriteImagePaths(content); + content = rewriteSiblingLinks(content, section); + content = convertCallouts(content); + writeFile(destPath, content); +} + +function processImages() { + if (!fs.existsSync(SRC_SCREENSHOTS_DIR)) { + console.log("No screenshots directory found, skipping image sync."); + return; + } + + const images = fs.readdirSync(SRC_SCREENSHOTS_DIR) + .filter(f => IMAGE_EXTENSIONS.has(path.extname(f).toLowerCase())); + + for (const img of images) { + const srcPath = path.join(SRC_SCREENSHOTS_DIR, img); + const ext = path.extname(img).toLowerCase(); + + if (CONVERT_WEBP && WEBP_CONVERTIBLE.has(ext)) { + const destName = img.slice(0, -ext.length) + ".webp"; + const destPath = path.join(DEST_IMAGES_DIR, destName); + + if (DRY_RUN) { + console.log(`[dry-run] Would convert: ${srcPath} → ${destPath}`); + } else { + ensureDir(path.dirname(destPath)); + try { + execSync(`cwebp -q 80 "${srcPath}" -o "${destPath}"`, { stdio: "pipe" }); + console.log(`Converted: ${destPath}`); + } catch (err) { + console.error(`Failed to convert ${img}: ${err.message}`); + // Fall back to copying the original + copyFile(srcPath, path.join(DEST_IMAGES_DIR, img)); + } + } + } else { + copyFile(srcPath, path.join(DEST_IMAGES_DIR, img)); + } + } +} + +function createIndexPage() { + const content = `--- +title: Android App +sidebar_position: 1 +--- + +# Meshtastic Android & Desktop App + +Documentation for the [Meshtastic Android](https://github.com/meshtastic/Meshtastic-Android) application, also available as a Desktop (JVM) app for Linux, macOS, and Windows. + +## Guides + +- **[User Guide](user/)** — Setup, messaging, nodes, maps, settings, and more +- **[Developer Guide](developer/)** — Architecture, KMP conventions, testing, and contributing +`; + writeFile(path.join(DEST_DOCS_DIR, "index.md"), content); +} + +function createCategoryFiles() { + const userCategory = `label: User Guide +position: 1 +`; + const devCategory = `label: Developer Guide +position: 2 +`; + writeFile(path.join(DEST_DOCS_DIR, "user", "_category_.yml"), userCategory); + writeFile(path.join(DEST_DOCS_DIR, "developer", "_category_.yml"), devCategory); +} + +function main() { + console.log(`Source: ${SRC_DOCS_DIR}`); + console.log(`Destination: ${DEST_DOCS_DIR}`); + console.log(`WebP conversion: ${CONVERT_WEBP ? "enabled" : "disabled"}`); + console.log(`Dry run: ${DRY_RUN}`); + console.log(""); + + // Process user guide + const userDir = path.join(SRC_DOCS_DIR, "user"); + if (fs.existsSync(userDir)) { + for (const file of fs.readdirSync(userDir).filter(f => f.endsWith(".md"))) { + processMarkdown( + path.join(userDir, file), + path.join(DEST_DOCS_DIR, "user", file), + "user", + ); + } + } + + // Process developer guide + const devDir = path.join(SRC_DOCS_DIR, "developer"); + if (fs.existsSync(devDir)) { + for (const file of fs.readdirSync(devDir).filter(f => f.endsWith(".md"))) { + processMarkdown( + path.join(devDir, file), + path.join(DEST_DOCS_DIR, "developer", file), + "developer", + ); + } + } + + // Create index and category files + createIndexPage(); + createCategoryFiles(); + + // Process images + processImages(); + + console.log("\nSync complete."); +} + +main(); diff --git a/scripts/validate-doc-links.js b/scripts/validate-doc-links.js new file mode 100644 index 000000000..dcd82c39f --- /dev/null +++ b/scripts/validate-doc-links.js @@ -0,0 +1,83 @@ +#!/usr/bin/env node +// scripts/validate-doc-links.js +// Validates internal cross-references and image paths in in-app documentation. +// Exit 0 = all valid, Exit 1 = broken links found. +// +// Usage: node scripts/validate-doc-links.js [docs-dir] + +"use strict"; + +const fs = require("fs"); +const path = require("path"); +const { discoverSlugs, forEachDocPage } = require("./lib/frontmatter"); + +const DOCS_DIR = path.resolve(process.argv[2] || "docs"); +const IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"]); + +// Collect known page slugs from both sections +const knownPages = new Set([ + ...discoverSlugs(DOCS_DIR, "user"), + ...discoverSlugs(DOCS_DIR, "developer"), +]); + +console.log(`Validating links across ${knownPages.size} doc pages in ${DOCS_DIR}...`); + +let errors = 0; + +forEachDocPage(DOCS_DIR, (filePath, slug, section) => { + const lines = fs.readFileSync(filePath, "utf-8").split("\n"); + + lines.forEach((line, idx) => { + const lineNum = idx + 1; + + // Check markdown links (non-image) + let match; + const linkRe = /(? 0) { + console.log(`\nFAILED: ${errors} broken link(s) found.`); + process.exit(1); +} else { + console.log("PASSED: All internal links and images are valid."); + process.exit(0); +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6d37b6559..da7e1295a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -104,6 +104,7 @@ include( ":feature:map", ":feature:node", ":feature:settings", + ":feature:docs", ":feature:firmware", ":feature:wifi-provision", ":desktopApp", diff --git a/specs/20260507-161858-app-docs-markdown/checklists/requirements.md b/specs/20260507-161858-app-docs-markdown/checklists/requirements.md index bdeedd356..f8a7b746c 100644 --- a/specs/20260507-161858-app-docs-markdown/checklists/requirements.md +++ b/specs/20260507-161858-app-docs-markdown/checklists/requirements.md @@ -19,6 +19,7 @@ - [x] Edge cases cover missing assets, unsupported AI environments, stale deep links, and degraded screenshot automation. - [x] The deep-link contract, keyword-index schema, and CI workflow contract are defined as separate artifacts. - [x] Search and AI fallback behavior are specified for unsupported targets and flavors. +- [x] Screenshot asset bundling and inline rendering via custom `ImageTransformer` are specified (FR-038). ## Android/KMP Adaptation Checks diff --git a/specs/20260507-161858-app-docs-markdown/plan.md b/specs/20260507-161858-app-docs-markdown/plan.md index 2abadbe85..370000a1d 100644 --- a/specs/20260507-161858-app-docs-markdown/plan.md +++ b/specs/20260507-161858-app-docs-markdown/plan.md @@ -104,6 +104,7 @@ specs/003-app-docs-markdown/ │ ├── ui/DocsBrowserScreen.kt │ ├── ui/DocsSearchBar.kt │ ├── ui/DocsPageRouteScreen.kt +│ ├── ui/ComposeResourceImageTransformer.kt │ ├── ui/ChirpyAssistantSheet.kt │ ├── navigation/DocsNavigation.kt │ ├── di/FeatureDocsModule.kt @@ -222,6 +223,54 @@ Possible internal task breakdown: - Keep the same content pipeline for both website and in-app docs. - Support Android WebView without forcing every target to use assets. +### Screenshot image adapter for Compose renderer (FR-038) + +The `DocsPageRouteScreen` on Desktop/iOS uses `com.mikepenz.markdown.m3.Markdown` for rendering. By default it uses `NoOpImageTransformerImpl`, which silently drops all `![alt](path)` image references. Two changes are needed to render screenshots inline: + +**1. Bundle screenshots into Compose resources** + +The `syncDocsToComposeResources` task must include `assets/screenshots/**/*.png` alongside `user/**/*.md` and `developer/**/*.md`. The `:screenshot-tests:copyDocsScreenshots` task must run first to populate `docs/screenshots/`, which is then synced as `assets/screenshots/` into compose resources. Task dependency: `syncDocsToComposeResources.dependsOn(":screenshot-tests:copyDocsScreenshots")`. + +**2. Custom `ImageTransformer` using `Res.getUri()` + Coil3** + +The CMP generated `Res` object provides two APIs: +- `suspend fun readBytes(path: String): ByteArray` — cannot be used in `@Composable` +- `fun getUri(path: String): String` — **synchronous**, returns a platform URI + +Since `ImageTransformer.transform(link: String)` is `@Composable` (not suspend), we CANNOT use `Res.readBytes()` directly. Instead, use `Res.getUri()` to resolve the local resource URI, then pass it to Coil3's `rememberAsyncImagePainter()`: + +```kotlin +// ComposeResourceImageTransformer.kt +class ComposeResourceImageTransformer : ImageTransformer { + @Composable + override fun transform(link: String): ImageData? { + if (link.startsWith("http://") || link.startsWith("https://")) return null + val resourcePath = "files/docs/$link" // e.g., "files/docs/assets/screenshots/foo.png" + val uri = Res.getUri(resourcePath) + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(uri) + .size(coil3.size.Size.ORIGINAL) + .build() + ) + return ImageData(painter) + } +} +``` + +**Why Coil3 after all?** The `ImageTransformer.transform()` is `@Composable`, not `suspend`. `Res.readBytes()` is a suspend function and cannot be called in composition. `Res.getUri()` gives us a synchronous local URI that Coil3 can load asynchronously via `rememberAsyncImagePainter`. Since the project already has Coil 3.4.0 in the version catalog, this is the correct approach — it handles the composable→async bridge that CMP resources require. + +**Dependencies**: Add `libs.coil` to `feature/docs/build.gradle.kts` commonMain dependencies. + +Wire into the renderer in `DocsPageRouteScreen.kt`: +```kotlin +Markdown( + content = markdownText, + imageTransformer = ComposeResourceImageTransformer(), + modifier = ... +) +``` + ## Navigation Plan ### Route additions @@ -359,6 +408,9 @@ Update `feature/settings/.../SettingsNavigation.kt` so Help & Documentation appe 7. **Phase 6 – AI assistant**: Android Google flavor Gemini implementation + fallbacks. 8. **Phase 7 – CI automation**: deploy/release workflows, screenshot PR bot. 9. **Phase 8 – Polish**: accessibility, dark mode, edge cases, documentation cleanup. +10. **Phase 9 – Apple alignment**: per-page TOC icons, signal-meter/units-locale pages, staleness CI. +11. **Phase 10 – Docusaurus sync & content gaps**: meshtastic.org sync script + workflow, WebP optimization, translate page, developer measurement page. +12. **Phase 11 – Governance consolidation**: shared frontmatter library, filesystem-derived slugs, workflow merge, 3-consumer propagation model, preview & screenshot staleness advisories. ## Validation Matrix @@ -379,3 +431,7 @@ Update `feature/settings/.../SettingsNavigation.kt` so Help & Documentation appe - Gemini Nano assistant works on supported Android Google devices and gracefully falls back everywhere else. - CI enforces schema, size, and deployment correctness. - Documentation and assets remain under the configured bundle-size ceiling. +- Android docs are synced to meshtastic.org via automated workflow under `docs/software/android/`. +- A "Translate the App" user page and a developer measurement page exist. +- All docs CI checks are consolidated into a single `docs-governance.yml` workflow. +- Governance scripts share a common frontmatter library with filesystem-derived slug discovery. diff --git a/specs/20260507-161858-app-docs-markdown/spec.md b/specs/20260507-161858-app-docs-markdown/spec.md index c2fc11e39..e03946d8b 100644 --- a/specs/20260507-161858-app-docs-markdown/spec.md +++ b/specs/20260507-161858-app-docs-markdown/spec.md @@ -147,6 +147,20 @@ When documentation-relevant UI or workflow changes merge to `main`, GitHub Actio - **FR-035**: Chirpy branding MUST use a shared SVG or vector drawable sourced from the Meshtastic design system and bundled as a scalable asset that renders crisply across Android, Desktop, and iOS. - **FR-036**: Connection-state icon captures and inline docs illustrations MUST use `MeshtasticIcons` equivalents and Material 3 semantic colors so they remain legible in both light and dark themes without relying on ad-hoc color inversion. - **FR-037**: Lock and security icon captures using `MeshtasticIcons.Lock`, `MeshtasticIcons.LockOpen`, `MeshtasticIcons.KeyOff`, or their final equivalents MUST preserve portrait aspect ratio at the shared 44dp reference height and MUST avoid canvas squashing in generated docs assets. +- **FR-039**: A sync script and GitHub Actions workflow MUST exist to export Android in-app docs into the `meshtastic/meshtastic` Docusaurus site under `docs/software/android/`. The script MUST handle relative link resolution (sibling `.md` links, image paths) and produce Docusaurus-compatible frontmatter. The workflow MUST open a PR in `meshtastic/meshtastic` rather than pushing directly, matching the pattern established by Apple's `sync-apple-docs.yml`. +- **FR-040**: The docs site sync pipeline SHOULD convert screenshot PNGs to WebP format before publishing to the Docusaurus site. A `--convert-webp` flag or equivalent MUST be supported. Original PNGs remain canonical in-repo; WebP is a site-publishing optimization only. +- **FR-041**: A `docs/user/translate.md` page MUST explain how contributors can translate the Android app via Crowdin. The page MUST link to the Crowdin project, describe which resource files are translatable (composeResources strings, user guide markdown), and provide step-by-step contribution instructions. This is a contributor guide, not a runtime translation feature. +- **FR-042**: A `docs/developer/measurement.md` page MUST document the `MetricFormatter` API, locale-aware unit conversion patterns, and how to add new measurement types. This provides developer-facing guidance complementing the user-facing `units-and-locale.md`. +- **FR-043**: Documentation governance MUST enforce a 3-consumer propagation model: every doc page automatically flows to (1) the in-app docs browser via `syncDocsToComposeResources`, (2) the Jekyll/GitHub Pages site via `docs-deploy.yml`, and (3) the Docusaurus meshtastic.org site via `sync-android-docs.js`. CI MUST validate that every `docs/**/*.md` page slug is registered in `DocBundleLoader.kt` for in-app discovery. +- **FR-044**: Doc governance scripts MUST share a common frontmatter parsing library (`scripts/lib/frontmatter.js`) to avoid duplication across link validation, freshness checks, coverage checks, and sync scripts. Slug discovery MUST be filesystem-derived, not hardcoded. +- **FR-045**: All docs CI checks (staleness, link validation, coverage, freshness, registry validation) MUST be consolidated into a single governance workflow with separate jobs for staleness detection and quality gates. +- **FR-046**: The governance workflow MUST include an advisory preview-staleness job that detects UI composable changes (`feature/*/ui/`, `core/ui/`) without corresponding `*Previews.kt` updates. The check MUST be bypassable via a `skip-preview-check` label. +- **FR-047**: The governance workflow MUST include an advisory screenshot-reference-staleness job that detects `*Previews.kt` changes without updates to reference images in `screenshot-tests/src/screenshotTestDebug/reference/`. The advisory MUST include the `updateDebugScreenshotTest` command. +- **FR-048**: User Guide documentation MUST support a three-tier translation cascade: (1) **Crowdin bundled** — community-translated markdown shipped with the app via Crowdin `%android_code%` locale directories synced to CMP `files-{qualifier}/` resources; (2) **ML Kit runtime** — on-device translation via `DocTranslationService` on supported Android `google` flavor devices when no Crowdin translation exists; (3) **English fallback** — the default `files/docs/` content used when neither bundled nor runtime translation is available. The `fdroid` flavor, Desktop, and iOS MUST use a `NoOpDocTranslator` that skips ML Kit and falls back to English. Developer Guide pages remain English-only. The locale resolution chain MUST try region-qualified (`files-pt-rBR/`) then language-only (`files-pt/`) before English. +- **FR-049**: The `crowdin.yml` configuration MUST use the `%android_code%` placeholder for docs translation paths so that Crowdin outputs locale directories in CMP resource qualifier format (e.g., `pt-rBR`, `zh-rCN`) with zero format conversion needed at build or runtime. The `syncTranslatedDocsToComposeResources` Gradle task MUST copy translated docs into `composeResources/files-{locale}/docs/` without locale format transformation. +- **FR-050**: The Jekyll documentation site MUST support locale-qualified paths using Android resource qualifier format (`docs/pt-rBR/user/*.md`) with a `_data/locales.yml` registry of supported locales including native names and text direction (LTR/RTL). The site MUST include a language switcher component. +- **FR-051**: A `currentLocaleQualifier()` expect/actual function in `core:common` MUST return the device locale in CMP resource qualifier format (e.g., `"pt-rBR"` or `"fr"`). This function is used by `DocBundleLoader` to resolve locale-qualified resource paths at runtime. +- **FR-038**: The Compose Multiplatform markdown renderer (`multiplatform-markdown-renderer-m3`) used by `DocsPageRouteScreen` on non-WebView targets MUST be configured with a custom `ImageTransformer` that resolves relative image paths (e.g., `assets/screenshots/*.png`) to bundled Compose resource URIs via `Res.getUri()` and loads them asynchronously using Coil 3 (`rememberAsyncImagePainter`). The default `NoOpImageTransformerImpl` MUST NOT be used for docs rendering. The `syncDocsToComposeResources` task MUST include screenshot assets alongside markdown files so that images are available at runtime. The `copyDocsScreenshots` task from `screenshot-tests/` MUST be wired as a dependency of `syncDocsToComposeResources` to ensure generated screenshots are available before resource bundling. ### Key Entities @@ -174,6 +188,17 @@ When documentation-relevant UI or workflow changes merge to `main`, GitHub Actio - **SC-012**: Icon/state screenshot coverage exists for connection state, security/lock state, node status, and at least one docs-browser rendering case. - **SC-013**: The Chirpy assistant appears as a chat interface with a bundled vector asset and preserves per-session message history while the browser is open. - **SC-014**: Connection and security icon assets remain legible in light and dark modes and preserve expected aspect ratio in generated docs output. +- **SC-015**: Android docs are published on meshtastic.org under `docs/software/android/` and stay current via automated sync workflow. +- **SC-016**: A "Translate the App" page exists in the User Guide and links to the Crowdin project with step-by-step contribution instructions. +- **SC-017**: A developer measurement/locale page exists documenting `MetricFormatter` internals and locale-aware patterns. +- **SC-018**: All docs CI checks (staleness, links, coverage, freshness, registry) run in a single consolidated `docs-governance.yml` workflow with no duplicate validation steps across workflows. +- **SC-019**: Sync script slug discovery is filesystem-derived — adding a new `.md` file under `docs/` requires no hardcoded string updates in scripts. +- **SC-020**: PRs that modify UI composables without updating previews receive an advisory PR comment with a checklist. The check is bypassable via `skip-preview-check` label. +- **SC-021**: PRs that modify previews without updating screenshot reference images receive an advisory PR comment with the regeneration command. +- **SC-022**: The `crowdin.yml` docs entries use `%android_code%` placeholders and the sync task copies translations into CMP resources with zero locale format conversion. +- **SC-023**: `DocBundleLoader` resolves locale-qualified docs resources using a region → language → English fallback chain driven by `currentLocaleQualifier()`. +- **SC-024**: On Google-flavor Android devices, ML Kit runtime translation is available as a fallback when no Crowdin bundled translation exists for the user's locale. +- **SC-025**: The Jekyll site supports 38 locale paths with a language switcher, and all locale directories use Android resource qualifier format (`pt-rBR`, not `pt-BR`). ## Clarifications @@ -202,3 +227,70 @@ When documentation-relevant UI or workflow changes merge to `main`, GitHub Actio - Gemini Nano availability is gated at runtime and may vary by hardware, region, downloaded models, and Google/AICore rollout. Unsupported environments must gracefully fall back to keyword search. - The `google` flavor can host AI bindings; `fdroid` must remain functional without requiring proprietary AI integrations. - Chirpy branding will be sourced from the Meshtastic design repository and packaged as a vector-compatible asset for all KMP targets. + +## Apple Alignment (Cross-Platform Parity) + +### Session 2026-05-12 + +Gap analysis against `meshtastic-apple` identified these alignment items for Android: + +**Implemented:** +1. **Per-page TOC icons** — Apple uses SF Symbols per `DocPage`; Android now maps `iconId` to `MeshtasticIcons` via `DocPageIconResolver.kt`. +2. **Signal meter user guide page** — `docs/user/signal-meter.md` explains RSSI vs SNR, bar-level criteria, and LoRa-specific signal concepts. Adapted from Apple equivalent for Android signal surfaces. +3. **Units & locale user guide page** — `docs/user/units-and-locale.md` explains automatic metric/imperial formatting via `MetricFormatter`. Adapted from Apple equivalent for Android/KMP stack. +4. **Docs staleness CI workflow** — `.github/workflows/docs-staleness.yml` posts advisory PR comments when user-facing UI files change without corresponding `docs/` updates. Adapted from Apple's workflow for KMP feature/core paths. + +**Skipped (platform-specific to Apple):** +- `docs/user/watch.md` — watchOS-only +- `docs/user/carplay.md` — iOS CarPlay only +- `docs/developer/carplay.md` — iOS CarPlay architecture only +- `docs/developer/swiftdata.md` — Android has `persistence.md` (Room KMP) +- `docs/developer/deep-links.md` — Android has `navigation-and-deep-links.md` +- TipKit contextual tips — iOS TipKit has no direct KMP equivalent; contextual help is deferred + +**Corrected (previously skipped, now implemented or planned):** +- `docs/user/translate.md` — Previously marked "iOS Translate framework only" but is actually a **Crowdin contribution guide** applicable to all platforms. Android equivalent planned as FR-041. +- `docs/developer/measurement.md` — Previously marked "covered by user page" but Apple version provides developer-facing API guidance. Android equivalent planned as FR-042. + +### Session 2026-05-13 + +Gap analysis against PR [meshtastic/meshtastic#2393](https://github.com/meshtastic/meshtastic/pull/2393) (Apple docs sync to Docusaurus) and `meshtastic-apple` `specs/003-app-docs-markdown/` identified these additional alignment items: + +**Planned (Phase 10):** +1. **Docusaurus sync script + workflow** — Apple has `sync-apple-docs.js` + `sync-apple-docs.yml` syncing in-app docs to meshtastic.org under `docs/software/apple/`. Android needs an equivalent `sync-android-docs.js` publishing to `docs/software/android/` (FR-039). +2. **WebP image optimization** — Apple converts PNGs to WebP for the docs site via `--convert-webp` flag. Android should add the same optimization (FR-040). +3. **Translate the App page** — `docs/user/translate.md` contributor guide for Crowdin (FR-041). +4. **Developer measurement page** — `docs/developer/measurement.md` for MetricFormatter API docs (FR-042). + +**Confirmed non-goals:** +- `docs/user/watch.md`, `docs/user/carplay.md`, `docs/developer/carplay.md` — remain Apple-only. + +**Implemented (Phase 11 — Governance & Consolidation):** + +Audit of docs infrastructure identified duplication across 4 JS scripts, 3 CI workflows, and hardcoded slug registries. Consolidation implemented: + +1. **Shared frontmatter library** — `scripts/lib/frontmatter.js` provides `parseFrontmatter()`, `discoverSlugs()`, and `forEachDocPage()` used by all governance scripts. Eliminated 4 independent frontmatter parsers (FR-044). +2. **Filesystem-derived slugs** — `sync-android-docs.js` now auto-discovers page slugs from `docs/user/` and `docs/developer/` instead of maintaining hardcoded `KNOWN_*_SLUGS` sets (26 strings eliminated). The slug registry CI check is no longer needed (FR-044). +3. **Workflow consolidation** — `docs-staleness.yml` merged into `docs-governance.yml` as a parallel `staleness` job alongside the existing `validate` job. Duplicate link validation removed from `docs-deploy.yml` (FR-045). +4. **3-consumer propagation** — Constitution principle VI updated to explicitly name in-app, Jekyll, and Docusaurus consumers with propagation rules. Staleness check comment includes new-page checklist (FR-043). +5. **Duplicate script removal** — `sync-android-docs.js` copy removed from meshtastic/meshtastic PR #2405 since the workflow runs from the Android repo clone. + +**Implemented (Phase 11 continued — Preview & Screenshot Governance):** + +Extended governance to cover preview composables and screenshot testing: + +1. **Preview staleness advisory** — `preview-staleness` job detects UI composable changes without `*Previews.kt` updates. Posts advisory PR comment with checklist. Bypassable via `skip-preview-check` label (FR-046). +2. **Screenshot reference staleness advisory** — Same job detects preview changes without reference image updates. Posts advisory with `updateDebugScreenshotTest` command (FR-047). +3. **Workflow renamed** — `Docs Governance` → `UI & Docs Governance` to reflect expanded scope. +4. **Contributing checklist** — `docs/developer.md` updated with preview/screenshot maintenance guidance. + +### Session 2026-05-18 + +Translation cascade and locale pipeline implementation: + +1. **Scope change** — Translating User Guide docs is now in scope. Crowdin provides community translations bundled with the app; ML Kit provides runtime fallback on Google flavor. Developer Guide remains English-only. +2. **Zero-conversion locale pipeline** — Crowdin `%android_code%` outputs locale directories in CMP resource qualifier format (`pt-rBR`, `fr`). The sync task just prepends `files-` — no format conversion at build or runtime. +3. **Locale resolution chain** — `DocBundleLoader.localeQualifiers()` tries region-qualified (`files-pt-rBR/`) → language-only (`files-pt/`) → English default (`files/`). +4. **Platform actuals** — `currentLocaleQualifier()` expect/actual in `core:common` returns CMP qualifier format on all platforms. +5. **ML Kit translation** — `DocTranslationService` interface with `MlKitDocTranslator` (google), `NoOpDocTranslator` (fdroid/desktop/iOS). `DocTranslationCache` provides file-backed caching. `MarkdownTranslationSegmenter` splits docs into translatable segments preserving markdown structure. +6. **Web i18n** — Jekyll `_config.yml` and `_data/locales.yml` support 38 locales with Android qualifier paths. Language switcher included. diff --git a/specs/20260507-161858-app-docs-markdown/tasks.md b/specs/20260507-161858-app-docs-markdown/tasks.md index 07f628046..f566024af 100644 --- a/specs/20260507-161858-app-docs-markdown/tasks.md +++ b/specs/20260507-161858-app-docs-markdown/tasks.md @@ -6,7 +6,7 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Input**: Design documents from `specs/003-app-docs-markdown/` **Prerequisites**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md` -**Status**: Not Started +**Status**: Complete (Phases 0–14) ## Format: `[ID] [P?] [Story] Description` @@ -20,8 +20,8 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Review Meshtastic design standards before shipping any new UI for docs or the Chirpy assistant. -- [ ] T000 **[UI-GATE]** Review `.skills/design-standards/SKILL.md` and upstream Meshtastic design standards; record constraints for `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt`, `ChirpyAssistantSheet.kt`, and screenshot styling. -- [ ] T001 **[UI-GATE]** Confirm icon choices in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/` for help/search/info/security states and choose MeshtasticIcons equivalents for docs UI and reference tables. +- [X] T000 **[UI-GATE]** Review `.skills/design-standards/SKILL.md` and upstream Meshtastic design standards; record constraints for `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt`, `ChirpyAssistantSheet.kt`, and screenshot styling. +- [X] T001 **[UI-GATE]** Confirm icon choices in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/` for help/search/info/security states and choose MeshtasticIcons equivalents for docs UI and reference tables. **Checkpoint**: Design constraints are documented and ready to guide implementation. @@ -32,35 +32,35 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Author the docs corpus that both the website and in-app browser will consume. ### User Guide pages -- [ ] T010 [P] [US1] Create `docs/user/onboarding.md` covering first launch, intro flow, permissions, and initial setup using content from `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt`, `LocationScreen.kt`, and `NotificationsScreen.kt`. -- [ ] T011 [P] [US1] Create `docs/user/connections.md` covering Bluetooth, USB, and TCP connection flows using `feature/intro/.../BluetoothScreen.kt` and `feature/connections/**` as authoritative sources. -- [ ] T012 [P] [US1] Create `docs/user/messages-and-channels.md` covering conversations, channel security, direct messages, and message state using `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt` and `component/MessageScreenComponents.kt`. -- [ ] T013 [P] [US1] Create `docs/user/nodes.md` covering node list status, roles, badges, and quick actions using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`. -- [ ] T014 [P] [US1] Create `docs/user/node-metrics.md` covering node detail, device metrics, environment metrics, signal, power, traceroute, and logs using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt` and `metrics/*`. -- [ ] T015 [P] [US1] Create `docs/user/map-and-waypoints.md` covering maps, waypoints, and map-specific actions using `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt`. -- [ ] T016 [P] [US1] Create `docs/user/settings-radio-user.md` covering radio, LoRa, display, and user settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt`. -- [ ] T017 [P] [US1] Create `docs/user/settings-module-admin.md` covering module, administration, and advanced settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt` and `AdministrationScreen.kt`. -- [ ] T018 [P] [US1] Create `docs/user/telemetry-and-sensors.md` covering telemetry surfaces and sensor interpretation using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt`, `PowerMetrics.kt`, and related metric screens. -- [ ] T019 [P] [US1] Create `docs/user/tak.md` covering TAK integration and setup using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt` and related settings screens. -- [ ] T020 [P] [US1] Create `docs/user/mqtt.md` covering MQTT setup and usage using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt` and messaging references. -- [ ] T021 [P] [US1] Create `docs/user/discovery.md` covering local mesh discovery and node exploration based on current discovery-related UI/state and app navigation flows. **Note**: Feature 001 (Local Mesh Discovery) is Not Started — author this page as a concept/goals overview initially and revise with screenshots and detailed UI guidance once 001 reaches Phase 5+ UI milestones. -- [ ] T022 [P] [US1] Create `docs/user/firmware.md` covering update flows, warnings, and recovery using `feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt`. -- [ ] T023 [P] [US1] Create `docs/user/desktop.md` covering Desktop host usage, transport differences, and parity notes using `desktop/src/main/kotlin/org/meshtastic/desktop/` and shared navigation patterns. +- [X] T010 [P] [US1] Create `docs/user/onboarding.md` covering first launch, intro flow, permissions, and initial setup using content from `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt`, `LocationScreen.kt`, and `NotificationsScreen.kt`. +- [X] T011 [P] [US1] Create `docs/user/connections.md` covering Bluetooth, USB, and TCP connection flows using `feature/intro/.../BluetoothScreen.kt` and `feature/connections/**` as authoritative sources. +- [X] T012 [P] [US1] Create `docs/user/messages-and-channels.md` covering conversations, channel security, direct messages, and message state using `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt` and `component/MessageScreenComponents.kt`. +- [X] T013 [P] [US1] Create `docs/user/nodes.md` covering node list status, roles, badges, and quick actions using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`. +- [X] T014 [P] [US1] Create `docs/user/node-metrics.md` covering node detail, device metrics, environment metrics, signal, power, traceroute, and logs using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt` and `metrics/*`. +- [X] T015 [P] [US1] Create `docs/user/map-and-waypoints.md` covering maps, waypoints, and map-specific actions using `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt`. +- [X] T016 [P] [US1] Create `docs/user/settings-radio-user.md` covering radio, LoRa, display, and user settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt`. +- [X] T017 [P] [US1] Create `docs/user/settings-module-admin.md` covering module, administration, and advanced settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt` and `AdministrationScreen.kt`. +- [X] T018 [P] [US1] Create `docs/user/telemetry-and-sensors.md` covering telemetry surfaces and sensor interpretation using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt`, `PowerMetrics.kt`, and related metric screens. +- [X] T019 [P] [US1] Create `docs/user/tak.md` covering TAK integration and setup using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt` and related settings screens. +- [X] T020 [P] [US1] Create `docs/user/mqtt.md` covering MQTT setup and usage using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt` and messaging references. +- [X] T021 [P] [US1] Create `docs/user/discovery.md` covering local mesh discovery and node exploration based on current discovery-related UI/state and app navigation flows. **Note**: Feature 001 (Local Mesh Discovery) is Not Started — author this page as a concept/goals overview initially and revise with screenshots and detailed UI guidance once 001 reaches Phase 5+ UI milestones. +- [X] T022 [P] [US1] Create `docs/user/firmware.md` covering update flows, warnings, and recovery using `feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt`. +- [X] T023 [P] [US1] Create `docs/user/desktop.md` covering Desktop host usage, transport differences, and parity notes using `desktop/src/main/kotlin/org/meshtastic/desktop/` and shared navigation patterns. ### Developer Guide pages -- [ ] T024 [P] [US4] Create `docs/developer/architecture.md` describing layer boundaries (`app`, `desktop`, `feature/*`, `core/*`) and shared KMP responsibilities. -- [ ] T025 [P] [US4] Create `docs/developer/codebase.md` documenting repository layout, namespacing, and build-logic conventions. -- [ ] T026 [P] [US4] Create `docs/developer/adding-a-feature-module.md` documenting `meshtastic.kmp.feature`, source sets, DI, resources, and testing expectations. -- [ ] T027 [P] [US4] Create `docs/developer/navigation-and-deep-links.md` documenting `Routes.kt`, `DeepLinkRouter.kt`, and Navigation 3 graph registration patterns. -- [ ] T028 [P] [US4] Create `docs/developer/transport.md` documenting BLE, TCP, Serial/USB, and host-specific abstractions. -- [ ] T029 [P] [US4] Create `docs/developer/persistence.md` documenting Room KMP, DataStore/core:prefs, and where docs intentionally do **not** use persistence. -- [ ] T030 [P] [US4] Create `docs/developer/testing.md` documenting KMP test strategy, host tests, and planned screenshot automation. -- [ ] T031 [P] [US4] Create `docs/developer/contributing.md` documenting branch naming, verification, and PR hygiene. +- [X] T024 [P] [US4] Create `docs/developer/architecture.md` describing layer boundaries (`app`, `desktop`, `feature/*`, `core/*`) and shared KMP responsibilities. +- [X] T025 [P] [US4] Create `docs/developer/codebase.md` documenting repository layout, namespacing, and build-logic conventions. +- [X] T026 [P] [US4] Create `docs/developer/adding-a-feature-module.md` documenting `meshtastic.kmp.feature`, source sets, DI, resources, and testing expectations. +- [X] T027 [P] [US4] Create `docs/developer/navigation-and-deep-links.md` documenting `Routes.kt`, `DeepLinkRouter.kt`, and Navigation 3 graph registration patterns. +- [X] T028 [P] [US4] Create `docs/developer/transport.md` documenting BLE, TCP, Serial/USB, and host-specific abstractions. +- [X] T029 [P] [US4] Create `docs/developer/persistence.md` documenting Room KMP, DataStore/core:prefs, and where docs intentionally do **not** use persistence. +- [X] T030 [P] [US4] Create `docs/developer/testing.md` documenting KMP test strategy, host tests, and planned screenshot automation. +- [X] T031 [P] [US4] Create `docs/developer/contributing.md` documenting branch naming, verification, and PR hygiene. ### Content-supporting assets -- [ ] T032 [P] [US1] Create or inventory `docs/assets/screenshots/` references and map each page to required PNG or SVG assets. -- [ ] T033 [P] [US1] Extract onboarding tips, warnings, and disclaimers from `feature/intro/**`, `feature/firmware/**`, and relevant feature UIs into highlighted callout sections inside the authored markdown. -- [ ] T034 [US1] Review all markdown for reference-table compliance where 2+ icon/state captures appear together. +- [X] T032 [P] [US1] Create or inventory `docs/assets/screenshots/` references and map each page to required PNG or SVG assets. +- [X] T033 [P] [US1] Extract onboarding tips, warnings, and disclaimers from `feature/intro/**`, `feature/firmware/**`, and relevant feature UIs into highlighted callout sections inside the authored markdown. +- [X] T034 [US1] Review all markdown for reference-table compliance where 2+ icon/state captures appear together. **Checkpoint**: Complete markdown corpus exists with planned screenshots and callouts. @@ -70,11 +70,11 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Make the authored markdown browsable on the web with versioning. -- [ ] T040 [P] [US1] Create `docs/_config.yml` with `just-the-docs`, sidebar search, and the required collection/navigation settings. -- [ ] T041 [P] [US1] Create `docs/index.md` redirect behavior for `/latest/` and beta handling. -- [ ] T042 [P] [US1] Create `docs/_data/versions.yml` with an initial `beta` entry and stable release entry schema. -- [ ] T043 [P] [US1] Create any shared include/layout files needed for version selector, beta banner, and consistent screenshot styling. -- [ ] T044 [US1] Validate local Jekyll build output from the authored markdown and confirm the navigation hierarchy matches the spec. +- [X] T040 [P] [US1] Create `docs/_config.yml` with `just-the-docs`, sidebar search, and the required collection/navigation settings. +- [X] T041 [P] [US1] Create `docs/index.md` redirect behavior for `/latest/` and beta handling. +- [X] T042 [P] [US1] Create `docs/_data/versions.yml` with an initial `beta` entry and stable release entry schema. +- [X] T043 [P] [US1] Create any shared include/layout files needed for version selector, beta banner, and consistent screenshot styling. +- [X] T044 [US1] Validate local Jekyll build output from the authored markdown and confirm the navigation hierarchy matches the spec. **Checkpoint**: Local website build is navigable and version-ready. @@ -84,17 +84,19 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Implement Gradle-native docs generation suitable for KMP. -- [ ] T050 [P] [US1] Create `feature/docs/build.gradle.kts` using `meshtastic.kmp.feature` and dependencies for `core:common`, `core:navigation`, `core:resources`, `core:ui`, `core:di`, and existing markdown renderer libraries. -- [ ] T051 [P] [US1] Add `:feature:docs` to `settings.gradle.kts`. -- [ ] T052 [P] [US1] Add docs-generation support in `build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt` (or equivalent) with lazy task registration. -- [ ] T053 [P] [US1] Implement frontmatter parsing, nav-order extraction, and markdown normalization in build logic or `feature/docs` build task code. -- [ ] T054 [P] [US1] Implement HTML rendering via `flexmark-java` (or `commonmark-java` fallback) in the docs generation task. -- [ ] T055 [P] [US1] Implement callout and banner post-processing, shared CSS injection, and `data-page` emission for generated HTML. -- [ ] T056 [P] [US1] Generate `index.json` matching `specs/003-app-docs-markdown/contracts/keyword-index-schema.json`. -- [ ] T057 [P] [US1] Wire generated output into `feature/docs/build/generated/docs/common/` as a Gradle resource source directory. -- [ ] T058 [P] [US1] Add Android asset mirroring if required for WebView file loading under `feature/docs/build/generated/docs/androidAssets/`. -- [ ] T059 [P] [US1] Enforce bundle-size warnings/failures and missing-asset validation in `validateDocsBundle`. -- [ ] T060 [US1] Add aggregate root tasks (`generateDocsBundle`, `validateDocsBundle`, `publishDocsSite`) and document their usage. +- [X] T050 [P] [US1] Create `feature/docs/build.gradle.kts` using `meshtastic.kmp.feature` and dependencies for `core:common`, `core:navigation`, `core:resources`, `core:ui`, `core:di`, and existing markdown renderer libraries. +- [X] T051 [P] [US1] Add `:feature:docs` to `settings.gradle.kts`. +- [X] T052 [P] [US1] Add docs-generation support in `build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt` (or equivalent) with lazy task registration. +- [X] T053 [P] [US1] Implement frontmatter parsing, nav-order extraction, and markdown normalization in build logic or `feature/docs` build task code. +- [X] T054 [P] [US1] Implement HTML rendering via `flexmark-java` (or `commonmark-java` fallback) in the docs generation task. +- [X] T055 [P] [US1] Implement callout and banner post-processing, shared CSS injection, and `data-page` emission for generated HTML. +- [X] T056 [P] [US1] Generate `index.json` matching `specs/003-app-docs-markdown/contracts/keyword-index-schema.json`. +- [X] T057 [P] [US1] Wire generated output into `feature/docs/build/generated/docs/common/` as a Gradle resource source directory. +- [X] T058 [P] [US1] Add Android asset mirroring if required for WebView file loading under `feature/docs/build/generated/docs/androidAssets/`. +- [X] T059 [P] [US1] Enforce bundle-size warnings/failures and missing-asset validation in `validateDocsBundle`. +- [X] T060 [US1] Add aggregate root tasks (`generateDocsBundle`, `validateDocsBundle`, `publishDocsSite`) and document their usage. + - [X] T061 [P] [US1] [FR-038] Update `syncDocsToComposeResources` in `feature/docs/build.gradle.kts` to include `assets/screenshots/**/*.png` alongside markdown files, and add a task dependency on `:screenshot-tests:copyDocsScreenshots` to ensure generated screenshots are populated before sync. +- [X] T062 [P] [US1] [FR-038] Rewrite or restructure markdown image paths during sync so `assets/screenshots/` references resolve to the compose resource file structure expected by the custom `ImageTransformer` at runtime. **Checkpoint**: Gradle can generate the docs bundle and website artifact from markdown. @@ -104,19 +106,22 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Ship the offline docs browser inside Settings. -- [ ] T070 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt` implementing the entities from `data-model.md`. -- [ ] T071 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt` to load packaged docs metadata and page content. -- [ ] T072 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt` with grouped TOC, search entry point, and loading/empty states. -- [ ] T073 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt` to route page IDs to renderer surfaces. -- [ ] T074 [P] [US2] Create Android renderer `feature/docs/src/androidMain/kotlin/org/meshtastic/feature/docs/ui/DocHtmlView.android.kt` using `AndroidView` + `WebView`. -- [ ] T075 [P] [US2] Create Desktop/iOS page renderers in `src/jvmMain` and `src/iosMain` using Compose markdown or embedded browser abstraction. -- [ ] T076 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt` with typed navigation entries. -- [ ] T077 [P] [US2] Add `SettingsRoute.HelpDocs` and `SettingsRoute.HelpDocPage` to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`. -- [ ] T078 [P] [US2] Update `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` for `help-docs` (canonical) / `helpDocs` (compat alias) routing. -- [ ] T079 [P] [US2] Update `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` to add the Help & Documentation row and register docs destinations. -- [ ] T080 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt`. -- [ ] T081 [P] [US2] Include `FeatureDocsModule` in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`. -- [ ] T082 [US2] Add shared/unit tests for bundle loading, page ordering, and route serialization under `feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/`. +- [X] T070 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt` implementing the entities from `data-model.md`. +- [X] T071 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt` to load packaged docs metadata and page content. +- [X] T072 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt` with grouped TOC, search entry point, and loading/empty states. +- [X] T073 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt` to route page IDs to renderer surfaces. +- [X] T074 [P] [US2] Create Android renderer `feature/docs/src/androidMain/kotlin/org/meshtastic/feature/docs/ui/DocHtmlView.android.kt` using `AndroidView` + `WebView`. +- [X] T075 [P] [US2] Create Desktop/iOS page renderers in `src/jvmMain` and `src/iosMain` using Compose markdown or embedded browser abstraction. +- [X] T076 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt` with typed navigation entries. +- [X] T077 [P] [US2] Add `SettingsRoute.HelpDocs` and `SettingsRoute.HelpDocPage` to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`. +- [X] T078 [P] [US2] Update `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` for `help-docs` (canonical) / `helpDocs` (compat alias) routing. +- [X] T079 [P] [US2] Update `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` to add the Help & Documentation row and register docs destinations. +- [X] T080 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt`. +- [X] T081 [P] [US2] Include `FeatureDocsModule` in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`. +- [X] T082 [US2] Add shared/unit tests for bundle loading, page ordering, and route serialization under `feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/`. +- [X] T083 [P] [US2] [FR-038] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ComposeResourceImageTransformer.kt` implementing `ImageTransformer` from mikepenz markdown renderer. Must use `Res.getUri("files/docs/$link")` (synchronous) to resolve local resource URIs, then `rememberAsyncImagePainter()` from Coil 3 to load the image composably. Must return `null` for external `http://`/`https://` URLs. Add `libs.coil` dependency to `feature/docs/build.gradle.kts` commonMain. +- [X] T084 [P] [US2] [FR-038] Update `DocsPageRouteScreen.kt` to pass `ComposeResourceImageTransformer()` as the `imageTransformer` parameter to the `Markdown()` composable instead of using the default `NoOpImageTransformerImpl`. +- [X] T085 [US2] [FR-038] Verify inline screenshot rendering end-to-end: run `copyDocsScreenshots`, `syncDocsToComposeResources`, then launch the docs browser on Desktop and confirm images render inline on a page with `![alt](...)` references. **Checkpoint**: Help & Documentation opens inside Settings and reads bundled content offline. @@ -126,12 +131,12 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Make the docs corpus searchable on all targets. -- [ ] T090 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt` using `KeywordIndexEntry`. -- [ ] T091 [P] [US2] Add alias normalization and title-first ranking logic. -- [ ] T092 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt` and wire it into `DocsBrowserScreen.kt`. -- [ ] T093 [P] [US2] Add section-aware search results and page suggestions for missing page/deep-link cases. -- [ ] T094 [P] [US2] Add tests for ranking, aliases, and tie-breaking in `KeywordSearchEngineTest.kt`. -- [ ] T095 [US2] Ensure keyword search is the user-visible fallback on unsupported AI targets. +- [X] T090 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt` using `KeywordIndexEntry`. +- [X] T091 [P] [US2] Add alias normalization and title-first ranking logic. +- [X] T092 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt` and wire it into `DocsBrowserScreen.kt`. +- [X] T093 [P] [US2] Add section-aware search results and page suggestions for missing page/deep-link cases. +- [X] T094 [P] [US2] Add tests for ranking, aliases, and tie-breaking in `KeywordSearchEngineTest.kt`. +- [X] T095 [US2] Ensure keyword search is the user-visible fallback on unsupported AI targets. **Checkpoint**: Search works without AI on every target. @@ -141,17 +146,17 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Add an Android-only on-device assistant without breaking KMP or `fdroid`. -- [ ] T100 [P] [US3] Create shared AI contracts in `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt` and result/state models. -- [ ] T101 [P] [US3] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt` with chat UI, pinned input, session history, and source-page chips. -- [ ] T102 [P] [US3] Add keyword-retrieval + token-budget helper logic in shared code. -- [ ] T103 [P] [US3] Implement Google-flavor Android binding under `app/src/google/kotlin/org/meshtastic/app/docs/GoogleDocsAiModule.kt` (or equivalent) to call Gemini Nano via Google AI Edge SDK. -- [ ] T104 [P] [US3] Bind a no-op or keyword-only fallback implementation in `app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt`. -- [ ] T105 [P] [US3] Bind a Desktop fallback implementation from `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`. -- [ ] T105b [P] [US3] Bind an iOS fallback implementation (keyword-search-only, sharing the Desktop fallback pattern) in the iOS Koin module or via a shared non-Android default binding. -- [ ] T106 [P] [US3] Add runtime capability checks for Android API level, flavor, model availability, and busy/quota states. -- [ ] T107 [P] [US3] Surface assistant fallback states cleanly in the shared UI and hide the input entirely when unsupported. -- [ ] T108 [P] [US3] Add tests covering token budget trimming, unsupported platform behavior, and fallback search suggestions. -- [ ] T109 [US3] Verify the Chirpy vector asset is bundled and rendered correctly across targets. +- [X] T100 [P] [US3] Create shared AI contracts in `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt` and result/state models. +- [X] T101 [P] [US3] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt` with chat UI, pinned input, session history, and source-page chips. +- [X] T102 [P] [US3] Add keyword-retrieval + token-budget helper logic in shared code. +- [X] T103 [P] [US3] Implement Google-flavor Android binding under `app/src/google/kotlin/org/meshtastic/app/docs/GoogleDocsAiModule.kt` (or equivalent) to call Gemini Nano via Google AI Edge SDK. +- [X] T104 [P] [US3] Bind a no-op or keyword-only fallback implementation in `app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt`. +- [X] T105 [P] [US3] Bind a Desktop fallback implementation from `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`. +- [X] T105b [P] [US3] Bind an iOS fallback implementation (keyword-search-only, sharing the Desktop fallback pattern) in the iOS Koin module or via a shared non-Android default binding. +- [X] T106 [P] [US3] Add runtime capability checks for Android API level, flavor, model availability, and busy/quota states. +- [X] T107 [P] [US3] Surface assistant fallback states cleanly in the shared UI and hide the input entirely when unsupported. +- [X] T108 [P] [US3] Add tests covering token budget trimming, unsupported platform behavior, and fallback search suggestions. +- [X] T109 [US3] Verify the Chirpy vector asset is bundled and rendered correctly across targets. **Checkpoint**: Supported Android Google builds get Gemini Nano; all other targets fall back gracefully. @@ -161,14 +166,14 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Keep docs current and deployable. -- [ ] T120 [P] [US5] Create `.github/workflows/docs-deploy.yml` using `ubuntu-24.04`, JDK 21, Gradle setup, docs-generation tasks, and Pages deploy steps. -- [ ] T121 [P] [US5] Create `.github/workflows/docs-release.yml` for `v*.*.*` tags, version manifest updates, and `/latest/` redirect refresh. -- [ ] T122 [P] [US5] Create or wire `recordDocsScreenshots` to the chosen screenshot framework (`Roborazzi` preferred, `Paparazzi` acceptable). -- [ ] T123 [P] [US5] Add screenshot asset diff detection and automated PR creation logic for changed PNGs. -- [ ] T124 [P] [US5] Add schema validation against `specs/003-app-docs-markdown/contracts/keyword-index-schema.json` during CI. -- [ ] T125 [P] [US5] Add bundle-size validation and missing-asset validation to CI as blocking steps. -- [ ] T126 [P] [US5] Update workflow permissions and Pages artifact publishing configuration. -- [ ] T127 [US5] Dry-run the workflows locally as far as practical and verify contract alignment. +- [X] T120 [P] [US5] Create `.github/workflows/docs-deploy.yml` using `ubuntu-24.04`, JDK 21, Gradle setup, docs-generation tasks, and Pages deploy steps. +- [X] T121 [P] [US5] Create `.github/workflows/docs-release.yml` for `v*.*.*` tags, version manifest updates, and `/latest/` redirect refresh. +- [X] T122 [P] [US5] Create or wire `recordDocsScreenshots` to the chosen screenshot framework (`Roborazzi` preferred, `Paparazzi` acceptable). +- [X] T123 [P] [US5] Add screenshot asset diff detection and automated PR creation logic for changed PNGs. +- [X] T124 [P] [US5] Add schema validation against `specs/003-app-docs-markdown/contracts/keyword-index-schema.json` during CI. +- [X] T125 [P] [US5] Add bundle-size validation and missing-asset validation to CI as blocking steps. +- [X] T126 [P] [US5] Update workflow permissions and Pages artifact publishing configuration. +- [X] T127 [US5] Dry-run the workflows locally as far as practical and verify contract alignment. **Checkpoint**: Docs build, validate, and deploy automatically in CI. @@ -178,15 +183,15 @@ description: "Task list for feature: App Documentation (Android/KMP)" **Purpose**: Final quality pass before implementation is considered complete. -- [ ] T130 [P] [US2] Add accessibility labels, headings, and focus order checks to docs browser and Chirpy UI. -- [ ] T131 [P] [US2] Validate dark-mode rendering for generated HTML, screenshots, and icon reference tables. -- [ ] T132 [P] [US2] Handle missing-page and stale-deep-link fallbacks in the docs browser UI. -- [ ] T133 [P] [US3] Add explicit user messaging for Gemini busy/quota/model-not-installed states. -- [ ] T134 [P] [US1] Review all pages for plain-language voice, no internal jargon leaks, and consistency with current UI strings. -- [ ] T135 [P] [US4] Review developer docs for correctness against actual modules, routes, and DI setup. -- [ ] T136 [P] [US5] Validate Lighthouse accessibility on the generated site and record results. -- [ ] T137 [P] [US5] Add README updates for Help & Documentation and the deep-link contract. -- [ ] T138 [US1] Run final verification: `./gradlew spotlessCheck detekt kmpSmokeCompile test allTests generateDocsBundle validateDocsBundle publishDocsSite`. +- [X] T130 [P] [US2] Add accessibility labels, headings, and focus order checks to docs browser and Chirpy UI. +- [X] T131 [P] [US2] Validate dark-mode rendering for generated HTML, screenshots, and icon reference tables. +- [X] T132 [P] [US2] Handle missing-page and stale-deep-link fallbacks in the docs browser UI. +- [X] T133 [P] [US3] Add explicit user messaging for Gemini busy/quota/model-not-installed states. +- [X] T134 [P] [US1] Review all pages for plain-language voice, no internal jargon leaks, and consistency with current UI strings. +- [X] T135 [P] [US4] Review developer docs for correctness against actual modules, routes, and DI setup. +- [X] T136 [P] [US5] Validate Lighthouse accessibility on the generated site and record results. +- [X] T137 [P] [US5] Add README updates for Help & Documentation and the deep-link contract. +- [X] T138 [US1] Run final verification: `./gradlew spotlessCheck detekt kmpSmokeCompile test allTests generateDocsBundle validateDocsBundle publishDocsSite`. **Checkpoint**: Feature is accessible, correct, and release-ready. @@ -197,10 +202,15 @@ description: "Task list for feature: App Documentation (Android/KMP)" - Phase 0 blocks all UI work. - Phase 1 (content) and Phase 2 (site scaffolding) can overlap. - Phase 3 must finish before Phase 4 can load generated bundles reliably. +- T083/T084 (ImageTransformer) depend on T061/T062 (screenshots must be bundled before the transformer can resolve them). - Phase 5 depends on Phase 3 metadata/index generation and Phase 4 browser UI. - Phase 6 depends on Phase 5 because AI retrieval uses the keyword index and search engine. - Phase 7 depends on Phases 2 and 3. - Phase 8 depends on all preceding phases. +- Phase 10 depends on Phases 1–9 (all content and CI must be in place before Docusaurus sync). +- Phase 11 depends on Phases 9–10 (governance workflows and sync script must exist before consolidation). +- Phase 12 depends on Phase 6 (Chirpy assistant must exist before UX polish). +- Phase 13 depends on Phase 12 (Chirpy bubble redesign must exist before further polish). ## Recommended Delivery Order @@ -209,3 +219,243 @@ description: "Task list for feature: App Documentation (Android/KMP)" 3. Add **US3** (Gemini Nano + fallbacks). 4. Finish **US4** polishing and architecture docs. 5. Finish **US5** automation and screenshot bot flow. + +--- + +## Phase 9: Apple Alignment (Cross-Platform Feature Parity) + +**Purpose**: Close feature gaps identified by comparing with `meshtastic-apple` docs implementation. + +- [X] T200 [P] [US1] Create `docs/user/signal-meter.md` explaining LoRa signal quality, RSSI vs SNR, bar-level criteria, and common misconceptions — adapted from Apple equivalent for Android-specific signal surfaces. +- [X] T201 [P] [US1] Create `docs/user/units-and-locale.md` explaining automatic metric/imperial formatting via `MetricFormatter`, covering temperature, distance, speed, wind, rainfall, and locale settings — adapted from Apple equivalent for Android/KMP. +- [X] T202 [P] [US2] Add `iconId: String?` field to `DocPage` and `KeywordIndexEntry` models in `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt`. +- [X] T203 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocPageIconResolver.kt` mapping `iconId` values to `MeshtasticIcons` vectors (equivalent to Apple's SF Symbols per-page mapping). +- [X] T204 [P] [US2] Update `DocsBrowserScreen.kt` TOC list items to show leading icon using `resolveIcon()`. +- [X] T205 [P] [US2] Update `DocBundleLoader.kt` static index with `iconId` for all 24 pages and add two new `KeywordIndexEntry` entries for `signal-meter` and `units-and-locale`. +- [X] T206 [P] [US5] Create `.github/workflows/docs-staleness.yml` — advisory CI workflow that posts a PR comment when user-facing UI files change without corresponding `docs/` updates, with `skip-docs-check` label bypass (adapted from Apple's `docs-staleness.yml` for Android KMP paths). + +**Checkpoint**: Feature parity with Apple docs: per-page icons in TOC, two new user guide pages, and docs staleness CI check. + +--- + +## Phase 10: Docusaurus Sync & Content Gaps (meshtastic.org Parity) + +**Purpose**: Close gaps identified by comparing with Apple's `sync-apple-docs.js` workflow (PR [meshtastic/meshtastic#2393](https://github.com/meshtastic/meshtastic/pull/2393)) and Apple in-app doc content. Ensures Android docs are published on meshtastic.org alongside Apple docs and addresses missing content pages. + +**Depends on**: Phases 1–9 (all content and CI must be in place before sync). + +### Content + +- [X] T210 [P] [US1] [FR-041] Create `docs/user/translate.md` — "Translate the App" contributor guide explaining how to submit translations via Crowdin. Cover: link to Crowdin project, which files are translatable (composeResources `strings.xml`, `docs/user/*.md`), step-by-step workflow, and how to add a new locale. Add frontmatter with `nav_order: 17`. Add Crowdin string resources for title and keywords. +- [X] T211 [P] [US4] [FR-042] Create `docs/developer/measurement.md` — developer guide for the `MetricFormatter` API and locale-aware unit conversion. Cover: supported measurement types (temperature, distance, speed, wind, rainfall), how locale detection works, how to add a new measurement type, and testing patterns. Reference `core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/` formatters. +- [X] T212 [P] [US2] Update `DocBundleLoader.kt` static index with new pages (`translate`, `measurement`), `iconId` mappings, and `KeywordIndexEntry` entries. Update nav ordering for existing pages to accommodate the two new entries. + +### Docusaurus Sync Script + +- [X] T220 [P] [US5] [FR-039] Create `scripts/sync-android-docs.js` — Node.js script that reads `docs/user/*.md` and `docs/developer/*.md`, transforms them for Docusaurus compatibility (rewrite frontmatter to Docusaurus format, fix sibling `.md` links, rewrite image paths to `static/img/android/`), and writes output to a staging directory. Model after Apple's `scripts/sync-apple-docs.js` structure. +- [X] T221 [P] [US5] [FR-040] Add `--convert-webp` flag to `sync-android-docs.js` that converts PNG/JPG screenshots to WebP via `cwebp` and rewrites image references in markdown. Original PNGs remain canonical in-repo. +- [X] T222 [P] [US5] [FR-039] Create `.github/workflows/sync-android-docs.yml` — workflow triggered on push to `main` when `docs/**` files change. Steps: checkout, install Node.js and `webp`, run `sync-android-docs.js --convert-webp`, copy images to `static/img/android/`, and open a PR in `meshtastic/meshtastic` targeting `docs/software/android/`. Use `ubuntu-24.04` runner and `peter-evans/create-pull-request` or equivalent action. +- [X] T223 [US5] Dry-run the sync script locally: run `node scripts/sync-android-docs.js --convert-webp --dry-run` and verify output structure matches Docusaurus expectations (`docs/software/android/user/*.md`, `docs/software/android/developer/*.md`, `static/img/android/*.webp`). + +### Integration + +- [X] T230 [P] [US2] Add Crowdin string resources for `translate.md` title (`doc_title_translate`) and keywords (`doc_keywords_translate`) in `core/resources/src/commonMain/composeResources/values/strings.xml`. Run `python3 scripts/sort-strings.py`. +- [X] T231 [P] [US2] Add Crowdin string resources for `measurement.md` title (`doc_title_measurement`) and keywords (`doc_keywords_measurement`). Run `python3 scripts/sort-strings.py`. +- [X] T232 [US1] Update `docs/user.md` and `docs/developer.md` What's New sections to include `translate.md` and `measurement.md`. Jekyll scope-based defaults handle nav/sidebar automatically. +- [X] T233 [US5] Verified `crowdin.yml` glob `/docs/user/*.md` already covers `translate.md` — no update needed. +- [X] T234 [US1] Run final verification: `./gradlew spotlessApply detekt :feature:docs:allTests`. + +**Checkpoint**: Android docs published on meshtastic.org, translate contributor page live, developer measurement docs complete. + +--- + +## Phase 11: Governance Consolidation & Script Optimization + +**Purpose**: Eliminate duplication across docs governance scripts and CI workflows. Reduce the number of places that must be manually updated when adding a doc page from 3 to 2 (markdown file + DocBundleLoader only). + +**Depends on**: Phases 9–10 (governance workflows and sync script must exist). + +### Shared Library + +- [X] T240 [P] [US5] [FR-044] Create `scripts/lib/frontmatter.js` with `parseFrontmatter()`, `discoverSlugs()`, and `forEachDocPage()` utilities. Consolidates 4 independent frontmatter parsers and directory traversal patterns. +- [X] T241 [P] [US5] [FR-044] Refactor `scripts/validate-doc-links.js` to use shared `discoverSlugs()` and `forEachDocPage()`. +- [X] T242 [P] [US5] [FR-044] Refactor `scripts/check-doc-freshness.js` to use shared `parseFrontmatter()` and `forEachDocPage()`. +- [X] T243 [P] [US5] [FR-044] Refactor `scripts/check-doc-coverage.js` to use shared `forEachDocPage()`. +- [X] T244 [P] [US5] [FR-044] Refactor `scripts/sync-android-docs.js` to use shared `discoverSlugs()` — replace hardcoded `KNOWN_USER_SLUGS` and `KNOWN_DEV_SLUGS` sets with filesystem-derived discovery. + +### Workflow Consolidation + +- [X] T250 [P] [US5] [FR-045] Merge `docs-staleness.yml` into `docs-governance.yml` as a parallel `staleness` job. The staleness job uses `fetch-depth: 0` for git diff; the `validate` job uses `fetch-depth: 1`. +- [X] T251 [P] [US5] [FR-045] Remove standalone `.github/workflows/docs-staleness.yml`. +- [X] T252 [US5] Remove slug registry validation step from `docs-governance.yml` (no longer needed since slugs are filesystem-derived). +- [X] T253 [US5] Remove duplicate link validation step and Node.js setup from `docs-deploy.yml`. Remove unused `pull-requests: write` permission. + +### 3-Consumer Propagation + +- [X] T260 [P] [US5] [FR-043] Update Constitution principle VI to explicitly name in-app, Jekyll, and Docusaurus consumers with propagation rules. +- [X] T261 [US5] Update staleness check PR comment to include new-page checklist for all 3 consumer registries. +- [X] T262 [US5] Add `DocBundleLoader` registry validation step to `docs-governance.yml` (ensures every doc page is registered in the in-app index). + +### Cleanup + +- [X] T270 [US5] Remove duplicate `sync-android-docs.js` from meshtastic/meshtastic PR #2405 (workflow runs from Android clone). +- [X] T271 [US5] Update `docs/developer.md` references from `docs-staleness` to consolidated `Docs Governance` workflow. +- [X] T272 [US5] Verify all 4 scripts pass locally: `validate-doc-links`, `check-doc-freshness`, `check-doc-coverage`, `sync-android-docs --dry-run`. + +**Checkpoint**: Single docs governance workflow, shared frontmatter library, filesystem-derived slugs, 3-consumer propagation model enforced. + +### Preview & Screenshot Governance + +- [X] T280 [P] [US5] [FR-046] Add `preview-staleness` job to `docs-governance.yml` — detects UI composable changes without `*Previews.kt` updates. Posts advisory PR comment with checklist. Bypassable via `skip-preview-check` label. +- [X] T281 [P] [US5] [FR-047] Add screenshot reference staleness detection to same job — detects `*Previews.kt` changes without reference image updates in `screenshot-tests/src/screenshotTestDebug/reference/`. Posts advisory with `updateDebugScreenshotTest` command. +- [X] T282 [US5] Rename workflow `Docs Governance` → `UI & Docs Governance` to reflect expanded scope. +- [X] T283 [US5] Update `docs/developer.md` contributing checklist with preview/screenshot maintenance guidance. +- [X] T284 [US5] Add dismiss-on-resolve logic: clear preview/screenshot advisory comments when both conditions resolve. + +**Checkpoint**: Unified UI & Docs Governance workflow with advisory checks for docs, previews, and screenshot references. + +--- + +## Phase 12: Chirpy UX & M3 Adaptive Nav Polish + +**Purpose**: Bring Chirpy assistant and docs navigation up to M3 adaptive navigation best practices and improve conversational UX. + +### M3 Adaptive Navigation + +- [X] T300 [P] [US2] Integrate `ListDetailSceneStrategy` metadata into `DocsNavigation.kt` — `listPane()` for `HelpDocs`, `detailPane()` for `HelpDocPage`. Enables proper dual-pane layout on tablets/desktop. +- [X] T301 [P] [US2] Add `feature/docs/build.gradle.kts` dependency on `libs.jetbrains.compose.material3.adaptive.navigation3`. + +### Global Chirpy State + +- [X] T310 [P] [US3] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/ChirpySessionHolder.kt` — Koin `@Single` with Compose snapshot state (`showSheet`, `sessionState`) for shared Chirpy conversation across panes. +- [X] T311 [P] [US3] Refactor `DocsNavigation.kt` `rememberChirpyState()` to inject `ChirpySessionHolder` and derive `showFab` from backstack — FAB shows on list pane only when no detail is selected, always on detail pane. +- [X] T312 [P] [US3] Add auto-intro prompt: Chirpy generates a natural introduction when the sheet first opens with no messages. + +### Chirpy Bubble Redesign (MessageItem Parity) + +- [X] T320 [P] [US3] Rewrite `ChirpyAssistantSheet.kt` bubbles to use `Surface` + `BorderStroke(0.5.dp)` + `RoundedCornerShape` matching `MessageItem.kt` sender/receiver pattern — user bubbles right-aligned with `primaryContainer`, Chirpy bubbles left-aligned with `surfaceVariant`. +- [X] T321 [P] [US3] Add 24dp Chirpy avatar (`img_chirpy`) to the left of every assistant reply bubble. +- [X] T322 [P] [US3] Update `DocsPreviews.kt` with matching bubble styles and avatar. + +### Thinking State & Source Navigation + +- [X] T330 [P] [US3] Replace plain "Chirpy is thinking..." text with proper `ThinkingBubble` composable — assistant-styled bubble with Chirpy avatar and pulsing alpha animation. +- [X] T331 [P] [US3] Add `SourceRef(id, title)` data class to `DocModels.kt`; update `ChirpyMessage.sources` to carry page titles alongside IDs. +- [X] T332 [P] [US3] Replace plain-text source list with tappable `SuggestionChip`s in `AssistantBubble` using `FlowRow` layout and `secondaryContainer` colors. +- [X] T333 [P] [US3] Add `onNavigateToPage` to `ChirpyUiState` — dismisses sheet and navigates to referenced doc page. Wire through `DocsBrowserScreen` and `DocsPageRouteScreen`. +- [X] T334 [US3] Update `DocsPreviews.kt` with `SourceRef` sample data, `PreviewThinkingBubble`, and chip-enabled `ChirpyBubble`. + +### Verification + +- [X] T340 [US3] Verify M3 FAB behavior: confirmed no existing FABs implement hide-on-scroll (consistent with M3 guidelines which do not prescribe it). Chirpy FAB is always-visible, matching all other FABs in the app. +- [X] T341 [US3] Build, detekt, spotless, and all `feature:docs` tests pass. Deployed and verified on Pixel 9 Pro. + +**Checkpoint**: Chirpy assistant follows M3 adaptive nav best practices with global state, MessageItem-style bubbles, thinking animation, and tappable source chips. + +--- + +## Phase 13: Chirpy Messaging UI Polish & Firebase AI Hybrid + +> Align Chirpy chat with messaging module conventions; add markdown rendering; update Firebase AI binding. + +- Phase 13 depends on Phase 12 (Chirpy bubble redesign must exist before further polish). + +### Firebase AI Logic Hybrid API + +- [X] T350 [P] [US3] Update `GeminiNanoDocAssistant.kt` to use `gemini-2.5-flash-lite` model with `InferenceMode.PREFER_ON_DEVICE` — hybrid on-device/cloud inference via Firebase AI Logic. +- [X] T351 [P] [US3] Implement paragraph extraction with markdown stripping and 8K character context budget with 3K retry fallback on token limit errors. +- [X] T352 [P] [US3] Migrate imports from deprecated `com.google.firebase.ai.ondevice` to `com.google.firebase.ai`. + +### Markdown Rendering in Assistant Messages + +- [X] T360 [US3] Replace `Text()` with mikepenz `Markdown()` composable in `AssistantBubble` — Chirpy responses now render rich markdown (headers, lists, bold, code blocks, links). + +### ChirpyChip Sender Label + +- [X] T370 [P] [US3] Create `ChirpyChip` composable in `ChirpyAssistantSheet.kt` — simplified `NodeChip` pattern using `Card` with `tertiaryContainer` colors, 28dp height, 18dp Chirpy avatar + "Chirpy" text label. +- [X] T371 [P] [US3] Replace inline avatar-beside-bubble layout in `AssistantBubble` and `ThinkingBubble` with `ChirpyChip` positioned above the bubble — matching how `NodeChip` appears above received messages in `MessageItem.kt`. + +### MessageInput-Style Text Field + +- [X] T380 [P] [US3] Replace `OutlinedTextField` + `TextButton("Send")` with messaging-style input: `RoundedCornerShape(50f)` pill shape, `IconButton` with `MeshtasticIcons.Send`. +- [X] T381 [P] [US3] Add `KeyboardOptions(capitalization = Sentences, imeAction = Send)` + `KeyboardActions(onSend)` for keyboard submit support. +- [X] T382 [P] [US3] Add `LocalSoftwareKeyboardController.current?.hide()` on send to dismiss keyboard after submitting a message. + +### Verification + +- [X] T390 [US3] Build, detekt, spotless, and all tests pass. Deployed and verified on Pixel 9 Pro. + +**Checkpoint**: Chirpy chat fully aligned with messaging module conventions — NodeChip-style sender label, MessageInput-style text field, markdown rendering, and Firebase AI hybrid inference. + +--- + +## Phase 14: Translation Cascade (Crowdin → ML Kit → English) + +**Purpose**: Enable runtime translation of bundled docs for users whose locale lacks Crowdin coverage. + +### Translation Service Interface & Implementations + +- [X] T400 [P] [US1] Create `DocTranslationService` interface in `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/translation/` with `translatePage()`, `isLanguageAvailable()`, `downloadLanguageModel()` and sealed result types. +- [X] T401 [P] [US1] Create `NoOpDocTranslator` for F-Droid/Desktop/iOS that returns `Unavailable`. +- [X] T402 [P] [US1] Create `MlKitDocTranslator` in `androidApp/src/google/kotlin/org/meshtastic/app/translation/` with auto model download, segment-and-translate pattern, and proper `suspendCancellableCoroutine` bridging. + +### Markdown-Aware Translation + +- [X] T410 [P] [US1] Create `MarkdownTranslationSegmenter` that extracts translatable text from markdown while preserving code blocks, links, images, frontmatter, and HTML blocks. +- [X] T411 [P] [US1] Create `DocTranslationCache` with Okio file-based caching, MD5 content keying, Mutex-guarded concurrency, atomic writes, and access-time eviction at 50MB. + +### Cascade Integration + +- [X] T420 [US1] Add `hasTranslatedResource()` to `DocBundleLoader` to detect Crowdin-provided locale-qualified bundles. +- [X] T421 [US1] Wire cascade into `DocsPageScreen`: show English content immediately, attempt ML Kit translation in background only when Crowdin bundle is absent, auto-download model on first use. +- [X] T422 [US1] Add `TranslationSource` model enum and UI indicator (subtitle in TopAppBar: "Community translated" or "Auto-translated"). +- [X] T423 [US1] Add `ioDispatcher` hop and locale-keyed `LaunchedEffect` for correct threading and reactivity. + +### DI & Platform Wiring + +- [X] T430 [P] [US1] Bind `DocTranslationService` → `MlKitDocTranslator` in `GoogleAiModule`. +- [X] T431 [P] [US1] Bind `DocTranslationService` → `NoOpDocTranslator` in `DesktopKoinModule`. + +### Testing + +- [X] T440 [P] [US1] Create `MarkdownTranslationSegmenterTest` (15 tests covering paragraphs, headings, code, links, images, frontmatter, lists, tables, HTML blocks). +- [X] T441 [P] [US1] Create `DocTranslationCacheTest` (8 tests covering cache miss/hit, stale hash, locale isolation, clear, size, eviction, hash consistency). +- [X] T442 [P] [US1] Create `TranslationCascadeTest` (8 tests covering NoOp behavior, fake translator variations, sealed hierarchy). + +### CI + +- [X] T450 [US1] Add `docs/**/*.md` to `scheduled-updates.yml` `add-paths`. + +**Checkpoint**: Translation cascade complete — Crowdin bundled translations served automatically by CMP, ML Kit auto-translates on Google flavor when Crowdin unavailable, graceful English fallback on all other platforms. + +--- + +## Phase 15: Web i18n — Crowdin Translations on GitHub Pages + +**Purpose**: Ensure in-repo Crowdin translations flow to web consumers (GH Pages docs site), not just the in-app bundle. + +### Jekyll Configuration + +- [X] T500 [P] Add `_data/locales.yml` with all supported locale metadata (name, text direction). +- [X] T501 [P] Add scope defaults in `_config.yml` for each locale path (`es`, `fr`, `de`, etc.) with `layout: locale_page` and `nav_exclude: true`. +- [X] T502 [P] Create `_layouts/locale_page.html` — wraps content with locale banner, language tag, RTL support, and link back to English. + +### Language Switcher UI + +- [X] T510 [P] Create `_includes/language_switcher.html` — detects available translations for current page from `site.pages`, renders dropdown with locale links. +- [X] T511 [P] Add language switcher CSS to `_includes/head_custom.html` (dropdown, hover states, dark-mode compatible). +- [X] T512 [P] Wire language switcher into `_includes/header_custom.html` alongside theme toggle. + +### DocsTasks Locale Generation + +- [X] T520 [P] Extend `GenerateDocsBundleTask` to discover `docs/{locale}/user/` directories and generate locale-qualified HTML + index entries. +- [X] T521 [P] Add `locales.json` manifest output listing all detected translation locales. +- [X] T522 [P] Add `locale` field to index.json entries for locale-aware consumers. +- [X] T523 [P] Set `lang` and `dir` attributes on generated HTML for locale pages. + +### Content & Navigation + +- [X] T530 [P] Create `docs/translations.md` — lists all available languages with links, Crowdin CTA, contribution instructions. +- [X] T531 [P] Crowdin config (`crowdin.yml`) already maps `docs/index.md` → `docs/{locale}/index.md` — locale landing pages auto-generated. + +**Checkpoint**: Crowdin-contributed translations serve to web consumers via Jekyll GH Pages with locale routing, language switcher, and proper locale/RTL HTML attributes. Same markdown source serves both in-app (CMP bundle) and web (Jekyll) consumers.