feat(docs): In-app documentation browser with Jekyll site and Docusaurus sync (#5445)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -3,6 +3,21 @@
|
||||
# Do NOT edit or remove previous entries — stale state claims cause agent confusion.
|
||||
# Format: ## YYYY-MM-DD — <summary>
|
||||
|
||||
## 2026-05-12 — Implemented Apple alignment for docs feature (FR-038)
|
||||
- Branch: `feat/20260507-161858-app-docs-markdown`
|
||||
- Gap analysis against `meshtastic-apple` completed. Implemented 4 alignment items:
|
||||
1. Per-page TOC icons via `DocPageIconResolver.kt` mapping `iconId` to `MeshtasticIcons`
|
||||
2. New `docs/user/signal-meter.md` (RSSI vs SNR, bar-level criteria, LoRa signal concepts)
|
||||
3. New `docs/user/units-and-locale.md` (automatic metric/imperial via `MetricFormatter`)
|
||||
4. New `.github/workflows/docs-staleness.yml` (advisory PR comments for UI changes without doc updates)
|
||||
- Added `iconId: String?` field to `DocPage` and `KeywordIndexEntry` models
|
||||
- Updated `DocBundleLoader` with iconId for all 24 pages plus 2 new entries (signal-meter, units-and-locale)
|
||||
- Updated `DocsBrowserScreen` to show leading icons in TOC list items
|
||||
- Marked T061-T085 as completed in tasks.md (were implemented in prior session)
|
||||
- Added Phase 9 (T200-T206) for Apple alignment tasks — all marked complete
|
||||
- Skipped Apple-only features: watch, carplay, translate, TipKit, SwiftData docs
|
||||
- Verified: `spotlessApply`, `detekt`, `assembleDebug`, `compileKotlinJvm` — all green
|
||||
|
||||
## 2026-05-11 — Migrated feature/intro UI to commonMain
|
||||
- Moved intro onboarding UI composables and nav graph from `feature/intro/src/androidMain/` into `feature/intro/src/commonMain/`, adding shared `IntroPermissions` and `IntroSettingsNavigator` interfaces plus a common `introGraph` Navigation 3 extension.
|
||||
- Refactored `AppIntroductionScreen` into a thin Android host that provides Android permission/settings adapters via composition locals, and added `AndroidIntroPermissions`, `AndroidIntroSettingsNavigator`, and JVM desktop no-op stubs.
|
||||
|
||||
63
.github/workflows/docs-deploy.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Docs Deploy (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'feature/docs/**'
|
||||
- 'build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt'
|
||||
- '.github/workflows/docs-deploy.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: docs-deploy
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: false
|
||||
|
||||
- name: Generate Docs Bundle
|
||||
run: ./gradlew generateDocsBundle -Pdocs.channel=beta -Pci=true
|
||||
|
||||
- name: Validate Docs Bundle
|
||||
run: ./gradlew validateDocsBundle -Pdocs.channel=beta -Pci=true
|
||||
|
||||
- name: Generate Site Artifact
|
||||
run: ./gradlew publishDocsSite -Pdocs.channel=beta -Pci=true
|
||||
|
||||
- name: Upload Pages Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/_site/
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
403
.github/workflows/docs-governance.yml
vendored
Normal file
@@ -0,0 +1,403 @@
|
||||
name: UI & Docs Governance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
schedule:
|
||||
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# ── Job 1: Staleness — flag PRs that change UI without updating docs ────────
|
||||
staleness:
|
||||
name: Docs staleness check
|
||||
runs-on: ubuntu-24.04-arm
|
||||
if: >-
|
||||
github.event_name == 'pull_request'
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'skip-docs-check')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed files
|
||||
id: changed
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
changed=$(git diff --name-only "$BASE" "$HEAD")
|
||||
|
||||
views_changed=$(echo "$changed" | grep -E \
|
||||
'^feature/.*/src/commonMain/.*/(ui|component|screen)/|^feature/.*/src/androidMain/.*/ui/|^core/ui/src/commonMain/' \
|
||||
| grep -v 'Test\|Preview\|__Snapshots__' || true)
|
||||
|
||||
docs_changed=$(echo "$changed" | grep -E '^docs/(user|developer)/' || true)
|
||||
|
||||
echo "views_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$views_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "docs_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$docs_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ -n "$views_changed" && -z "$docs_changed" ]]; then
|
||||
echo "stale=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "stale=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post warning comment
|
||||
if: steps.changed.outputs.stale == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const viewsChanged = `${{ steps.changed.outputs.views_changed }}`.trim();
|
||||
const body = [
|
||||
'## 📄 Docs staleness check — advisory',
|
||||
'',
|
||||
'This PR modifies user-facing UI source files but does not update any page under `docs/user/` or `docs/developer/`.',
|
||||
'',
|
||||
'> ⚠️ Doc changes propagate to **3 consumers**: in-app docs browser, Jekyll site (GitHub Pages), and meshtastic.org (Docusaurus sync). Updating a page in `docs/` automatically flows to all three.',
|
||||
'',
|
||||
'**Changed source files:**',
|
||||
'```',
|
||||
viewsChanged,
|
||||
'```',
|
||||
'',
|
||||
'**What to check:**',
|
||||
'| Changed area | Likely doc page |',
|
||||
'|---|---|',
|
||||
'| `feature/messaging/` | `docs/user/messages-and-channels.md` |',
|
||||
'| `feature/node/` | `docs/user/nodes.md` or `docs/user/node-metrics.md` |',
|
||||
'| `feature/map/` | `docs/user/map-and-waypoints.md` |',
|
||||
'| `feature/connections/` | `docs/user/connections.md` |',
|
||||
'| `feature/settings/` | `docs/user/settings-radio-user.md` or `docs/user/settings-module-admin.md` |',
|
||||
'| `feature/firmware/` | `docs/user/firmware.md` |',
|
||||
'| `feature/intro/` | `docs/user/onboarding.md` |',
|
||||
'| `feature/discovery/` | `docs/user/discovery.md` |',
|
||||
'| `feature/docs/` | Internal docs infrastructure |',
|
||||
'| `core/ui/` | `docs/developer/codebase.md` or component-specific user pages |',
|
||||
'',
|
||||
'**New page checklist** (if adding a new doc page):',
|
||||
'1. Create the `.md` file in `docs/user/` or `docs/developer/` with `last_updated` frontmatter',
|
||||
'2. Register in `DocBundleLoader.kt` with string resources (in-app browser)',
|
||||
'3. Jekyll and Docusaurus sync pick up new pages automatically — no config change needed',
|
||||
'',
|
||||
'If this PR does **not** require a doc update (e.g., internal refactor, bug fix, test change), add the **`skip-docs-check`** label to dismiss this check.',
|
||||
'',
|
||||
'> **Cross-platform note:** This check is advisory while doc coverage matures. Both Android and Apple repos use the same `skip-docs-check` label and advisory severity. See `meshtastic/design` standards for shared conventions.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c =>
|
||||
c.user.login === 'github-actions[bot]' &&
|
||||
c.body.includes('Docs staleness check')
|
||||
);
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Dismiss stale comment when docs are updated
|
||||
if: steps.changed.outputs.stale == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c =>
|
||||
c.user.login === 'github-actions[bot]' &&
|
||||
c.body.includes('Docs staleness check')
|
||||
);
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: '## ✅ Docs staleness check passed\n\nThis PR includes updates to `docs/` alongside the source changes. Thank you!',
|
||||
});
|
||||
}
|
||||
|
||||
- name: Advisory status
|
||||
if: steps.changed.outputs.stale == 'true'
|
||||
run: |
|
||||
echo "::warning::UI source files changed without corresponding docs/ updates."
|
||||
echo "Add the 'skip-docs-check' label if this PR does not require a doc update."
|
||||
echo "NOTE: This check is advisory while docs coverage matures across platforms."
|
||||
echo "To upgrade to blocking, change this step to 'exit 1'."
|
||||
|
||||
# ── Job 2: Quality gates — link validation, coverage, registry, freshness ───
|
||||
validate:
|
||||
name: Docs quality gates
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Validate internal links
|
||||
run: node scripts/validate-doc-links.js docs
|
||||
|
||||
- name: Check doc coverage
|
||||
run: node scripts/check-doc-coverage.js .
|
||||
|
||||
- name: Validate DocBundleLoader registry
|
||||
run: |
|
||||
loader="feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt"
|
||||
missing=0
|
||||
for f in docs/user/*.md docs/developer/*.md; do
|
||||
slug=$(basename "$f" .md)
|
||||
if ! grep -q "\"$slug\"" "$loader"; then
|
||||
echo "ERROR: $slug not registered in DocBundleLoader.kt"
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$missing" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "FAILED: $missing page(s) missing from DocBundleLoader.kt in-app index."
|
||||
exit 1
|
||||
fi
|
||||
echo "All doc pages registered in DocBundleLoader."
|
||||
|
||||
- name: Check doc freshness
|
||||
# Advisory — warns on stale pages but does not block PRs
|
||||
continue-on-error: true
|
||||
run: node scripts/check-doc-freshness.js docs --max-age-days=180
|
||||
|
||||
# ── Job 3: Preview staleness — flag UI changes without preview updates ──────
|
||||
preview-staleness:
|
||||
name: Preview staleness check
|
||||
runs-on: ubuntu-24.04-arm
|
||||
if: >-
|
||||
github.event_name == 'pull_request'
|
||||
&& !contains(github.event.pull_request.labels.*.name, 'skip-preview-check')
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect changed files
|
||||
id: changed
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
changed=$(git diff --name-only "$BASE" "$HEAD")
|
||||
|
||||
# UI composables changed (screens, components — excluding tests and previews)
|
||||
ui_changed=$(echo "$changed" | grep -E \
|
||||
'^(feature|core/ui)/.*/src/commonMain/.*/.*\.(kt)$' \
|
||||
| grep -E '/(ui|component|screen)/' \
|
||||
| grep -v 'Test\|Preview\|__Snapshots__' || true)
|
||||
|
||||
# Preview files changed
|
||||
preview_changed=$(echo "$changed" | grep -E 'Preview.*\.kt$' || true)
|
||||
|
||||
# Screenshot test files changed
|
||||
screenshot_tests_changed=$(echo "$changed" | grep -E '^screenshot-tests/src/screenshotTest/.*\.kt$' || true)
|
||||
|
||||
# Reference images changed
|
||||
refs_changed=$(echo "$changed" | grep -E '^screenshot-tests/src/screenshotTestDebug/reference/.*\.png$' || true)
|
||||
|
||||
echo "ui_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$ui_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "preview_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$preview_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "screenshot_tests_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$screenshot_tests_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "refs_changed<<EOF" >> "$GITHUB_OUTPUT"
|
||||
echo "$refs_changed" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Preview staleness: UI changed but no preview updates
|
||||
if [[ -n "$ui_changed" && -z "$preview_changed" ]]; then
|
||||
echo "preview_stale=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "preview_stale=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Screenshot staleness: previews changed but no reference image updates
|
||||
if [[ -n "$preview_changed" && -z "$refs_changed" ]]; then
|
||||
echo "screenshot_stale=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "screenshot_stale=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post preview advisory
|
||||
if: steps.changed.outputs.preview_stale == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const uiChanged = `${{ steps.changed.outputs.ui_changed }}`.trim();
|
||||
const body = [
|
||||
'## 🖼️ Preview staleness check — advisory',
|
||||
'',
|
||||
'This PR modifies UI composables but does not update any `*Previews.kt` files.',
|
||||
'',
|
||||
'> Previews power screenshot tests and in-app docs screenshots. Keeping them current ensures visual regression coverage stays accurate.',
|
||||
'',
|
||||
'**Changed UI files:**',
|
||||
'```',
|
||||
uiChanged,
|
||||
'```',
|
||||
'',
|
||||
'**What to check:**',
|
||||
'| Pattern | Preview file convention |',
|
||||
'|---|---|',
|
||||
'| `feature/{name}/…/ui/` or `component/` | `feature/{name}/…/*Previews.kt` |',
|
||||
'| `core/ui/…/` | `core/ui/…/` (previews colocated) |',
|
||||
'',
|
||||
'**Adding previews checklist:**',
|
||||
'1. Create or update a `*Previews.kt` file in the same module with `@PreviewLightDark`',
|
||||
'2. Add `@Suppress("PreviewPublic")` if the preview is consumed by screenshot-tests',
|
||||
'3. Add corresponding `@PreviewTest` function in `screenshot-tests/src/screenshotTest/`',
|
||||
'4. Run `./gradlew :screenshot-tests:updateDebugScreenshotTest` to generate reference images',
|
||||
'',
|
||||
'If this PR does **not** require preview updates (e.g., logic-only change, non-visual refactor), add the **`skip-preview-check`** label to dismiss.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c =>
|
||||
c.user.login === 'github-actions[bot]' &&
|
||||
c.body.includes('Preview staleness check')
|
||||
);
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Post screenshot advisory
|
||||
if: steps.changed.outputs.screenshot_stale == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const previewChanged = `${{ steps.changed.outputs.preview_changed }}`.trim();
|
||||
const body = [
|
||||
'## 📸 Screenshot reference staleness — advisory',
|
||||
'',
|
||||
'This PR modifies preview composables but does not update screenshot reference images.',
|
||||
'',
|
||||
'> Reference images in `screenshot-tests/src/screenshotTestDebug/reference/` must be regenerated when previews change, or `validateDebugScreenshotTest` will fail.',
|
||||
'',
|
||||
'**Changed preview files:**',
|
||||
'```',
|
||||
previewChanged,
|
||||
'```',
|
||||
'',
|
||||
'**How to update:**',
|
||||
'```bash',
|
||||
'./gradlew :screenshot-tests:updateDebugScreenshotTest',
|
||||
'```',
|
||||
'Then commit the updated reference PNGs.',
|
||||
'',
|
||||
'If this change is intentionally preview-only (e.g., adding a preview that doesn\'t need a test yet), add the **`skip-preview-check`** label.',
|
||||
].join('\n');
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c =>
|
||||
c.user.login === 'github-actions[bot]' &&
|
||||
c.body.includes('Screenshot reference staleness')
|
||||
);
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
- name: Dismiss comments when resolved
|
||||
if: >-
|
||||
steps.changed.outputs.preview_stale == 'false'
|
||||
&& steps.changed.outputs.screenshot_stale == 'false'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
for (const marker of ['Preview staleness check', 'Screenshot reference staleness']) {
|
||||
const existing = comments.find(c =>
|
||||
c.user.login === 'github-actions[bot]' &&
|
||||
c.body.includes(marker)
|
||||
);
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: `## ✅ ${marker} passed\n\nPreview and screenshot references are up to date.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
82
.github/workflows/docs-release.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Docs Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: docs-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-docs:
|
||||
runs-on: ubuntu-24.04
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract Version
|
||||
id: version
|
||||
run: |
|
||||
TAG=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Deploying docs for version: $TAG"
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
with:
|
||||
cache-read-only: true
|
||||
|
||||
- name: Generate Docs Bundle
|
||||
run: ./gradlew generateDocsBundle -Pdocs.channel=release -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
|
||||
|
||||
- name: Validate Docs Bundle
|
||||
run: ./gradlew validateDocsBundle -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
|
||||
|
||||
- name: Generate Site Artifact
|
||||
run: ./gradlew publishDocsSite -Pdocs.channel=release -Pdocs.version=${{ steps.version.outputs.version }} -Pci=true
|
||||
|
||||
- name: Update Versions Manifest
|
||||
run: |
|
||||
VERSION=${{ steps.version.outputs.version }}
|
||||
# Prepend new version entry to versions.yml
|
||||
cat <<EOF > /tmp/new_version.yml
|
||||
- name: "$VERSION"
|
||||
url: /v$VERSION/
|
||||
prerelease: false
|
||||
current: true
|
||||
EOF
|
||||
# Mark existing entries as not current
|
||||
sed -i 's/current: true/current: false/' docs/_data/versions.yml
|
||||
# Prepend new entry
|
||||
cat /tmp/new_version.yml docs/_data/versions.yml > /tmp/versions_merged.yml
|
||||
mv /tmp/versions_merged.yml docs/_data/versions.yml
|
||||
|
||||
- name: Upload Pages Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: build/_site/
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
|
||||
1
.github/workflows/scheduled-updates.yml
vendored
@@ -177,6 +177,7 @@ jobs:
|
||||
fastlane/metadata/android/**
|
||||
**/strings.xml
|
||||
**/README.md
|
||||
docs/**/*.md
|
||||
labels: |
|
||||
automation
|
||||
l10n
|
||||
|
||||
53
.github/workflows/sync-android-docs.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Sync Android App Documentation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # Every Sunday at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout meshtastic/meshtastic
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Clone meshtastic/Meshtastic-Android
|
||||
run: |
|
||||
git clone --depth=1 --branch main https://github.com/meshtastic/Meshtastic-Android.git /tmp/Meshtastic-Android
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install cwebp
|
||||
run: sudo apt-get update && sudo apt-get install -y webp
|
||||
|
||||
- name: Run sync script
|
||||
run: node /tmp/Meshtastic-Android/scripts/sync-android-docs.js /tmp/Meshtastic-Android --convert-webp
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: "docs: sync latest Android app documentation"
|
||||
branch: sync/android-docs
|
||||
delete-branch: true
|
||||
title: "docs: sync latest Android app documentation"
|
||||
body: |
|
||||
This PR was automatically created by the [sync-android-docs](../.github/workflows/sync-android-docs.yml) workflow.
|
||||
|
||||
It synchronizes Markdown documentation and images from [meshtastic/Meshtastic-Android](https://github.com/meshtastic/Meshtastic-Android) into this repository:
|
||||
|
||||
- Markdown files → `docs/software/android/`
|
||||
- Image files → `static/img/android/docs/`
|
||||
|
||||
Image paths in Markdown are rewritten to use the Docusaurus `/img/android/docs/` static path.
|
||||
Screenshots are converted to WebP for optimal site performance.
|
||||
15
.gitignore
vendored
@@ -51,6 +51,12 @@ docs/screenshots/
|
||||
build-and-install-android.sh
|
||||
wireless-install.sh
|
||||
|
||||
# Generated docs artifacts
|
||||
docs/_site/
|
||||
docs/.jekyll-cache/
|
||||
docs/.jekyll-metadata
|
||||
docs/Gemfile.lock
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
/firebase-debug.log.jdk/
|
||||
@@ -63,6 +69,13 @@ firebase-debug.log
|
||||
/coil/
|
||||
/kable/
|
||||
.opencode/
|
||||
# Synced docs in composeResources (generated from docs/ source by syncDocsToComposeResources task)
|
||||
feature/docs/src/commonMain/composeResources/files/docs/user/
|
||||
feature/docs/src/commonMain/composeResources/files/docs/developer/
|
||||
feature/docs/src/commonMain/composeResources/files/docs/assets/
|
||||
|
||||
/desktop/bin/
|
||||
/build-logic/convention/bin/
|
||||
/.specify/extensions/.cache/
|
||||
/.specify/extensions/.cache/
|
||||
# Jekyll local config (comments out remote_theme for local builds)
|
||||
docs/_config_local.yml
|
||||
|
||||
42
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<!--
|
||||
SYNC IMPACT REPORT
|
||||
==================
|
||||
Version change: 1.1.1 → 1.2.0
|
||||
Version change: 1.2.0 → 1.3.0
|
||||
Modified principles:
|
||||
- V. Design Standards Compliance (expanded to require cross-platform behavior spec
|
||||
from meshtastic/design/features/ for multi-platform features)
|
||||
Added sections: None.
|
||||
- VI. Verify Before Push → renumbered to VII.
|
||||
Added sections:
|
||||
- VI. Documentation Freshness (new principle requiring last_updated frontmatter,
|
||||
blocking staleness check, link validation, coverage checks, freshness warnings)
|
||||
Removed sections: None.
|
||||
Templates requiring updates:
|
||||
✅ .specify/templates/spec-template.md — Added Cross-Platform Spec metadata field and
|
||||
cross-platform check guidance in Summary comment.
|
||||
✅ .specify/templates/plan-template.md — Constitution Check V updated to require upstream
|
||||
spec link for cross-platform features.
|
||||
✅ .specify/templates/checklist-template.md — CHK005 updated to include cross-platform spec check.
|
||||
Follow-up TODOs: None.
|
||||
- .specify/templates/checklist-template.md — Add CHK006 for documentation freshness
|
||||
- .specify/templates/plan-template.md — Constitution Check VII for docs freshness
|
||||
Follow-up TODOs: Update AGENTS.md with docs governance reference.
|
||||
-->
|
||||
|
||||
# Meshtastic Android (KMP) Constitution
|
||||
@@ -90,7 +88,41 @@ All user-facing UI MUST conform to the Meshtastic Client Design Standards:
|
||||
users have a predictable experience regardless of platform. The design standards are
|
||||
maintained collaboratively across all Meshtastic client teams.
|
||||
|
||||
### VI. Verify Before Push
|
||||
### VI. Documentation Freshness
|
||||
|
||||
In-app documentation MUST remain accurate and current as the codebase evolves.
|
||||
Documentation changes propagate to **three consumers** — all three MUST be considered:
|
||||
|
||||
1. **In-app docs browser** — `syncDocsToComposeResources` copies `docs/` into Compose
|
||||
Resources at build time. Changes are bundled into the app automatically.
|
||||
2. **Jekyll site** (GitHub Pages) — `docs/` is served directly. The `docs-deploy.yml`
|
||||
workflow rebuilds on push to `main`.
|
||||
3. **Docusaurus site** (meshtastic.org) — `scripts/sync-android-docs.js` transforms
|
||||
`docs/` for the external site. Runs weekly via the `meshtastic/meshtastic` repo.
|
||||
|
||||
Governance rules:
|
||||
|
||||
- Every doc page MUST include a `last_updated` frontmatter field (YYYY-MM-DD).
|
||||
Update this field whenever page content changes.
|
||||
- PRs that modify user-facing UI source files MUST update the corresponding doc page(s)
|
||||
or apply the `skip-docs-check` label with justification. The docs staleness check is a
|
||||
**blocking** CI gate.
|
||||
- Internal cross-references between doc pages and image paths MUST be validated; broken
|
||||
links fail the `docs-governance` workflow.
|
||||
- Every user-facing feature module MUST have corresponding documentation in `docs/user/`
|
||||
or `docs/developer/`. Coverage is checked by `scripts/check-doc-coverage.js`.
|
||||
- Pages older than 180 days without updates trigger an advisory freshness warning.
|
||||
- New doc pages MUST be registered in `DocBundleLoader.kt` (in-app index), and added to
|
||||
the `KNOWN_*_SLUGS` sets in `sync-android-docs.js` (Docusaurus link resolution).
|
||||
Jekyll picks up new pages automatically via `_config.yml` scope-based defaults.
|
||||
- Image references MUST use root-relative paths (`/assets/screenshots/filename.png`) so
|
||||
they resolve correctly in both Jekyll and the in-app renderer. The sync script rewrites
|
||||
these to Docusaurus paths automatically.
|
||||
- Rationale: Documentation that drifts from the implementation misleads users, increases
|
||||
support burden, and undermines the in-app help experience. Three distinct consumers
|
||||
means a single source change must be verified across all delivery channels.
|
||||
|
||||
### VII. Verify Before Push
|
||||
|
||||
Local verification MUST complete successfully before any `git push`:
|
||||
|
||||
@@ -162,7 +194,7 @@ This constitution supersedes all other practices, coding guidelines, and agent i
|
||||
- PATCH: Clarifications, wording fixes, or non-semantic refinements.
|
||||
|
||||
**Compliance Review**: Every implementation plan and PR description MUST include a
|
||||
Constitution Check confirming all six principles were evaluated. Complexity violations
|
||||
Constitution Check confirming all seven principles were evaluated. Complexity violations
|
||||
require explicit justification in the Complexity Tracking table of the plan document.
|
||||
|
||||
**Version**: 1.2.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-05-12
|
||||
**Version**: 1.3.0 | **Ratified**: 2026-05-07 | **Last Amended**: 2026-05-13
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
|
||||
/** Provides keyword-only fallback AI assistant for the F-Droid flavor (no on-device model). */
|
||||
@Module
|
||||
class FdroidAiModule {
|
||||
@Single fun aiDocAssistant(fallback: KeywordFallbackAssistant): AIDocAssistant = fallback
|
||||
|
||||
@Single fun docTranslationService(): DocTranslationService = NoOpDocTranslator()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.ai
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.firebase.Firebase
|
||||
import com.google.firebase.ai.Chat
|
||||
import com.google.firebase.ai.DownloadStatus
|
||||
import com.google.firebase.ai.InferenceMode
|
||||
import com.google.firebase.ai.OnDeviceConfig
|
||||
import com.google.firebase.ai.OnDeviceModelStatus
|
||||
import com.google.firebase.ai.ai
|
||||
import com.google.firebase.ai.type.GenerativeBackend
|
||||
import com.google.firebase.ai.type.PublicPreviewAPI
|
||||
import com.google.firebase.ai.type.Tool
|
||||
import com.google.firebase.ai.type.content
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.data.DocBundleLoader
|
||||
import org.meshtastic.feature.docs.data.KeywordSearchEngine
|
||||
import org.meshtastic.feature.docs.model.AIDocAssistantResult
|
||||
import org.meshtastic.feature.docs.model.DocPage
|
||||
import org.meshtastic.feature.docs.model.DocsAiError
|
||||
|
||||
/**
|
||||
* Gemini on-device AI assistant for the Google flavor.
|
||||
*
|
||||
* Uses Firebase AI Logic hybrid SDK with [InferenceMode.ONLY_ON_DEVICE] for fast first-message answers, and a cloud
|
||||
* model with URL context grounding for follow-up conversation. Supported on Pixel 9+, Samsung Galaxy S25/S26, OnePlus
|
||||
* 13/15, and other devices with AICore.
|
||||
*
|
||||
* Context strategy: extracts only the **most relevant paragraphs** from each page (those containing query terms),
|
||||
* strips markdown formatting to maximize information density, and fits within the on-device token budget (~4K tokens).
|
||||
* This ensures fast, offline-capable answers even without cloud fallback.
|
||||
*
|
||||
* @see <a href="https://firebase.google.com/docs/ai-logic/hybrid/android/get-started">Firebase AI Logic Hybrid</a>
|
||||
*/
|
||||
@OptIn(PublicPreviewAPI::class)
|
||||
class GeminiNanoDocAssistant(private val searchEngine: KeywordSearchEngine, private val bundleLoader: DocBundleLoader) :
|
||||
AIDocAssistant {
|
||||
|
||||
/** On-device model for fast first-message answers grounded in bundled docs. */
|
||||
private val onDeviceModel by lazy {
|
||||
Firebase.ai(backend = GenerativeBackend.googleAI())
|
||||
.generativeModel(
|
||||
modelName = MODEL_NAME,
|
||||
systemInstruction = content { text(SYSTEM_INSTRUCTION) },
|
||||
onDeviceConfig = OnDeviceConfig(mode = InferenceMode.ONLY_ON_DEVICE),
|
||||
)
|
||||
}
|
||||
|
||||
/** Cloud model with URL context — fetches only from meshtastic.org and github.com/meshtastic. */
|
||||
private val groundedModel by lazy {
|
||||
Firebase.ai(backend = GenerativeBackend.googleAI())
|
||||
.generativeModel(
|
||||
modelName = MODEL_NAME,
|
||||
systemInstruction = content { text(SYSTEM_INSTRUCTION) },
|
||||
tools = listOf(Tool.urlContext()),
|
||||
)
|
||||
}
|
||||
|
||||
/** Active multi-turn chat session. Maintains conversation history across messages. */
|
||||
private var chatSession: Chat? = null
|
||||
private var messageCount = 0
|
||||
|
||||
override suspend fun isSupported(): Boolean = try {
|
||||
// Always supported on Google flavor — cloud model with Google Search grounding is always available.
|
||||
// On-device model provides faster/offline answers when available; check status to trigger download.
|
||||
val ext = onDeviceModel.onDeviceExtension
|
||||
val status = ext?.checkStatus()
|
||||
Logger.d(tag = TAG) { "On-device model status: $status" }
|
||||
when (status) {
|
||||
OnDeviceModelStatus.AVAILABLE -> true
|
||||
|
||||
OnDeviceModelStatus.DOWNLOADING -> true
|
||||
|
||||
OnDeviceModelStatus.DOWNLOADABLE -> {
|
||||
Logger.i(tag = TAG) { "Model downloadable — requesting download" }
|
||||
ext.download().collect { downloadStatus ->
|
||||
when (downloadStatus) {
|
||||
is DownloadStatus.DownloadStarted ->
|
||||
Logger.d(tag = TAG) { "Download started: ${downloadStatus.bytesToDownload} bytes" }
|
||||
|
||||
is DownloadStatus.DownloadInProgress ->
|
||||
Logger.d(tag = TAG) {
|
||||
"Download progress: ${downloadStatus.totalBytesDownloaded} bytes"
|
||||
}
|
||||
|
||||
is DownloadStatus.DownloadCompleted -> Logger.i(tag = TAG) { "Model download completed" }
|
||||
|
||||
is DownloadStatus.DownloadFailed ->
|
||||
Logger.w(tag = TAG) { "Model download failed: $downloadStatus" }
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
else -> true // Cloud grounded model is always available even without on-device support.
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(tag = TAG) { "isSupported() check failed, using cloud only: ${e.message}" }
|
||||
true // Cloud grounded model is always available.
|
||||
}
|
||||
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
override suspend fun answer(question: String, currentPageId: String?): AIDocAssistantResult = try {
|
||||
val bundle = bundleLoader.load()
|
||||
val queryTerms = extractQueryTerms(question)
|
||||
|
||||
// Load all page content for full-text search ranking.
|
||||
val allContent = bundle.pages.associateWith { page -> bundleLoader.readPage(page.id)?.markdown.orEmpty() }
|
||||
|
||||
// Rank pages by relevance: full-text content search + keyword/title matching.
|
||||
val rankedPages = rankPagesByRelevance(queryTerms, bundle.pages, allContent)
|
||||
Logger.d(tag = TAG) { "Ranked pages: ${rankedPages.take(5).map { "${it.first.id}(${it.second})" }}" }
|
||||
|
||||
// Build compact context by extracting only relevant paragraphs.
|
||||
val contextResult = buildContext(currentPageId, queryTerms, rankedPages, allContent, MAX_CONTEXT_CHARS)
|
||||
Logger.d(tag = TAG) {
|
||||
"Context: ${contextResult.parts.size} pages, ${contextResult.totalChars} chars (budget $MAX_CONTEXT_CHARS)"
|
||||
}
|
||||
|
||||
val prompt = buildPrompt(question, contextResult.parts)
|
||||
Logger.d(tag = TAG) { "Prompt: ${prompt.length} chars, message #$messageCount" }
|
||||
|
||||
val chatResult = generateWithChat(prompt)
|
||||
messageCount++
|
||||
Logger.d(tag = TAG) { "Response (${chatResult.answer.length} chars): ${chatResult.answer.take(200)}" }
|
||||
|
||||
// Merge context pages with any pages mentioned by title in the response (à la Meshtastic-Apple).
|
||||
val mentionedPages =
|
||||
bundle.pages.filter { page ->
|
||||
page.id !in contextResult.usedPageIds && chatResult.answer.contains(page.title, ignoreCase = true)
|
||||
}
|
||||
val allSourcePages =
|
||||
contextResult.usedPageIds.mapNotNull { id -> bundle.pages.find { it.id == id } } + mentionedPages
|
||||
|
||||
AIDocAssistantResult.Success(
|
||||
answer = chatResult.answer,
|
||||
sourcePages = allSourcePages,
|
||||
usedOnDeviceModel = chatResult.usedOnDevice,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Logger.w(tag = TAG) { "Inference failed: ${e.message}" }
|
||||
val errorType =
|
||||
when {
|
||||
e.message?.contains("BUSY", ignoreCase = true) == true -> DocsAiError.Busy
|
||||
e.message?.contains("BATTERY", ignoreCase = true) == true -> DocsAiError.Busy
|
||||
e.message?.contains("BACKGROUND", ignoreCase = true) == true -> DocsAiError.Busy
|
||||
e.message?.contains("UNAVAILABLE", ignoreCase = true) == true -> DocsAiError.ModelUnavailable
|
||||
else -> DocsAiError.Unknown
|
||||
}
|
||||
val fallbackPages = searchEngine.selectForTokenBudget(question, maxChars = MAX_CONTEXT_CHARS)
|
||||
AIDocAssistantResult.Error(reason = errorType, suggestedPages = fallbackPages)
|
||||
}
|
||||
|
||||
override fun resetSession() {
|
||||
chatSession = null
|
||||
messageCount = 0
|
||||
Logger.d(tag = TAG) { "Chat session reset" }
|
||||
}
|
||||
|
||||
/** Result from [generateWithChat] indicating which model produced the answer. */
|
||||
private data class ChatResult(val answer: String, val usedOnDevice: Boolean)
|
||||
|
||||
/**
|
||||
* Uses the Chat API for multi-turn conversation. First message tries on-device for speed; all messages also go
|
||||
* through the cloud chat session to maintain conversation history for follow-ups.
|
||||
*/
|
||||
private suspend fun generateWithChat(prompt: String): ChatResult {
|
||||
val chat = chatSession ?: groundedModel.startChat().also { chatSession = it }
|
||||
|
||||
// First message: try on-device in parallel for speed, use cloud chat as primary.
|
||||
if (messageCount == 0) {
|
||||
val onDeviceAnswer =
|
||||
try {
|
||||
val response = onDeviceModel.generateContent(prompt)
|
||||
response.text?.trimEnd()
|
||||
} catch (e: Exception) {
|
||||
Logger.d(tag = TAG) { "On-device inference failed: ${e.message}" }
|
||||
null
|
||||
}
|
||||
|
||||
// If on-device gave a good answer, send it to chat as history context and return it.
|
||||
if (onDeviceAnswer != null && !looksLikeNoAnswer(onDeviceAnswer)) {
|
||||
// Still send to cloud chat so it has context for follow-ups (fire and forget).
|
||||
try {
|
||||
val groundedPrompt = prompt + MESHTASTIC_URL_HINT
|
||||
chat.sendMessage(groundedPrompt)
|
||||
} catch (e: Exception) {
|
||||
Logger.d(tag = TAG) { "Cloud chat seeding failed (non-fatal): ${e.message}" }
|
||||
}
|
||||
return ChatResult(answer = onDeviceAnswer, usedOnDevice = true)
|
||||
}
|
||||
}
|
||||
|
||||
// Use cloud chat (maintains full conversation history for follow-ups).
|
||||
val groundedPrompt = prompt + MESHTASTIC_URL_HINT
|
||||
val response = chat.sendMessage(groundedPrompt)
|
||||
return ChatResult(
|
||||
answer = response.text?.trimEnd() ?: "I wasn't able to generate a response.",
|
||||
usedOnDevice = false,
|
||||
)
|
||||
}
|
||||
|
||||
/** Heuristic: detect when the model says it can't find the answer in the provided docs. */
|
||||
private fun looksLikeNoAnswer(answer: String): Boolean {
|
||||
val lower = answer.lowercase()
|
||||
return lower.contains("not in the docs") ||
|
||||
lower.contains("not found in") ||
|
||||
lower.contains("i don't have information") ||
|
||||
lower.contains("i couldn't find") ||
|
||||
lower.contains("not covered in the documentation")
|
||||
}
|
||||
|
||||
private data class ContextResult(val parts: List<String>, val usedPageIds: Set<String>, val totalChars: Int)
|
||||
|
||||
/** Builds context parts from ranked pages within the given char budget. */
|
||||
private fun buildContext(
|
||||
currentPageId: String?,
|
||||
queryTerms: List<String>,
|
||||
rankedPages: List<Pair<DocPage, Int>>,
|
||||
allContent: Map<DocPage, String>,
|
||||
budget: Int,
|
||||
): ContextResult {
|
||||
val usedPageIds = mutableSetOf<String>()
|
||||
val contextParts = mutableListOf<String>()
|
||||
var totalChars = 0
|
||||
|
||||
// Current page gets priority.
|
||||
if (currentPageId != null) {
|
||||
val content = allContent.entries.find { it.key.id == currentPageId }
|
||||
if (content != null && content.value.isNotBlank()) {
|
||||
val pageBudget = MAX_PAGE_CHARS.coerceAtMost(budget)
|
||||
val extracted = extractRelevantContent(content.key.title, content.value, queryTerms, pageBudget)
|
||||
if (extracted.isNotBlank()) {
|
||||
contextParts.add(extracted)
|
||||
totalChars += extracted.length
|
||||
usedPageIds.add(currentPageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add relevant paragraphs from top-ranked pages within budget.
|
||||
for ((page, _) in rankedPages) {
|
||||
if (page.id in usedPageIds) continue
|
||||
if (totalChars >= budget) break
|
||||
val pageContent = allContent[page] ?: continue
|
||||
if (pageContent.isBlank()) continue
|
||||
|
||||
val snippetBudget = (budget - totalChars).coerceAtMost(MAX_SNIPPET_CHARS)
|
||||
if (snippetBudget < MIN_USEFUL_SNIPPET) break
|
||||
|
||||
val extracted = extractRelevantContent(page.title, pageContent, queryTerms, snippetBudget)
|
||||
if (extracted.isNotBlank()) {
|
||||
contextParts.add(extracted)
|
||||
totalChars += extracted.length
|
||||
usedPageIds.add(page.id)
|
||||
}
|
||||
}
|
||||
|
||||
return ContextResult(parts = contextParts, usedPageIds = usedPageIds, totalChars = totalChars)
|
||||
}
|
||||
|
||||
/** Extracts query terms from a question, filtering short/stop words. */
|
||||
private fun extractQueryTerms(question: String): List<String> = question
|
||||
.lowercase()
|
||||
.replace(Regex("[^\\p{L}\\p{N}\\s-]"), " ")
|
||||
.split(Regex("\\s+"))
|
||||
.filter { it.length >= 2 && it !in STOP_WORDS }
|
||||
.distinct()
|
||||
|
||||
/**
|
||||
* Extracts the most relevant content from a page: the paragraphs that contain query terms, with markdown formatting
|
||||
* stripped for maximum information density.
|
||||
*/
|
||||
private fun extractRelevantContent(
|
||||
title: String,
|
||||
markdown: String,
|
||||
queryTerms: List<String>,
|
||||
maxChars: Int,
|
||||
): String {
|
||||
val plainText = stripMarkdown(markdown)
|
||||
|
||||
// Split into paragraphs (double newline or section breaks).
|
||||
val paragraphs = plainText.split(Regex("\n{2,}")).map { it.trim() }.filter { it.length >= MIN_PARAGRAPH_LEN }
|
||||
|
||||
// Score each paragraph by how many query terms it contains.
|
||||
val scored =
|
||||
paragraphs.map { paragraph ->
|
||||
val lower = paragraph.lowercase()
|
||||
val hits = queryTerms.count { term -> lower.contains(term) }
|
||||
paragraph to hits
|
||||
}
|
||||
|
||||
// Take paragraphs with hits first (sorted by hits desc), then fill with top paragraphs for context.
|
||||
val withHits = scored.filter { it.second > 0 }.sortedByDescending { it.second }
|
||||
val withoutHits = scored.filter { it.second == 0 }
|
||||
|
||||
val result = StringBuilder("$title: ")
|
||||
for ((paragraph, _) in withHits + withoutHits) {
|
||||
if (result.length + paragraph.length + 1 > maxChars) {
|
||||
// Try to fit a truncated version if we have room.
|
||||
val remaining = maxChars - result.length - 1
|
||||
if (remaining > MIN_USEFUL_SNIPPET) {
|
||||
result.append(paragraph.take(remaining))
|
||||
}
|
||||
break
|
||||
}
|
||||
result.append(paragraph).append('\n')
|
||||
}
|
||||
return result.toString().trim()
|
||||
}
|
||||
|
||||
/** Strips markdown formatting to produce dense plain text. */
|
||||
private fun stripMarkdown(markdown: String): String = markdown
|
||||
.replace(Regex("^#{1,6}\\s+", RegexOption.MULTILINE), "") // headers
|
||||
.replace(Regex("\\[([^]]+)]\\([^)]+\\)"), "$1") // links → text
|
||||
.replace(Regex("!\\[([^]]*)]\\([^)]+\\)"), "$1") // images → alt
|
||||
.replace(Regex("[*_]{1,3}([^*_]+)[*_]{1,3}"), "$1") // bold/italic
|
||||
.replace(Regex("`{1,3}[^`]*`{1,3}"), "") // inline code
|
||||
.replace(Regex("^[>|\\-*+]\\s?", RegexOption.MULTILINE), "") // block quotes, lists
|
||||
.replace(Regex("\\|"), " ") // table pipes
|
||||
.replace(Regex("-{3,}"), "") // horizontal rules
|
||||
.replace(Regex(" {2,}"), " ") // collapse whitespace
|
||||
.trim()
|
||||
|
||||
/**
|
||||
* Ranks pages by relevance using full-text content search + keyword/title matching. Returns pages sorted by score
|
||||
* descending, filtering out zero-score pages.
|
||||
*/
|
||||
private fun rankPagesByRelevance(
|
||||
queryTerms: List<String>,
|
||||
pages: List<DocPage>,
|
||||
allContent: Map<DocPage, String>,
|
||||
): List<Pair<DocPage, Int>> = pages
|
||||
.map { page ->
|
||||
var score = 0
|
||||
val content = allContent[page]?.lowercase().orEmpty()
|
||||
|
||||
for (term in queryTerms) {
|
||||
if (content.contains(term)) score += CONTENT_MATCH_SCORE
|
||||
if (page.title.lowercase().contains(term)) score += TITLE_MATCH_SCORE
|
||||
if (page.keywords.any { it.lowercase().contains(term) }) score += KEYWORD_MATCH_SCORE
|
||||
if (page.aliases.any { it.lowercase().contains(term) }) score += ALIAS_MATCH_SCORE
|
||||
}
|
||||
|
||||
page to score
|
||||
}
|
||||
.filter { it.second > 0 }
|
||||
.sortedByDescending { it.second }
|
||||
|
||||
private fun buildPrompt(question: String, contextParts: List<String>): String {
|
||||
val context =
|
||||
if (contextParts.isNotEmpty()) {
|
||||
contextParts.joinToString("\n\n")
|
||||
} else {
|
||||
FALLBACK_CONTEXT
|
||||
}
|
||||
return """
|
||||
|Bundled app documentation:
|
||||
|$context
|
||||
|
|
||||
|User question: $question
|
||||
"""
|
||||
.trimMargin()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ChirpyAI"
|
||||
|
||||
/** Gemini 3.1 Flash-Lite — latest stable model (2026-05-07), free tier, supports grounding. */
|
||||
private const val MODEL_NAME = "gemini-3.1-flash-lite"
|
||||
|
||||
private const val SYSTEM_INSTRUCTION =
|
||||
"""You are Chirpy, the friendly AI assistant built into the Meshtastic Android app. You help users understand mesh networking, configure their Meshtastic nodes, troubleshoot connectivity issues, and get the most out of the Meshtastic ecosystem.
|
||||
|
||||
Personality: Helpful, concise, enthusiastic about mesh networking. Use short paragraphs. Include relevant emoji sparingly (📡 🔋 📍).
|
||||
|
||||
Knowledge sources (in priority order):
|
||||
1. Bundled app documentation provided as context below
|
||||
2. Official Meshtastic documentation at meshtastic.org/docs
|
||||
3. Official Meshtastic GitHub repositories (github.com/meshtastic)
|
||||
4. General LoRa/mesh networking knowledge
|
||||
|
||||
Guidelines:
|
||||
- Answer the user's question directly and helpfully
|
||||
- When the bundled docs cover the topic, cite them
|
||||
- When the bundled docs don't cover it, use your knowledge of official Meshtastic sources — don't refuse to help
|
||||
- Only reference official Meshtastic sources (meshtastic.org, github.com/meshtastic) — never cite random forums, blogs, or third-party sites
|
||||
- For firmware-specific or hardware-specific questions beyond app scope, point users to meshtastic.org/docs
|
||||
- Keep answers concise (2-4 short paragraphs max) unless the user asks for detail
|
||||
- If you're truly unsure about something Meshtastic-specific, say so honestly rather than guessing"""
|
||||
|
||||
/** Total context char budget — sized for on-device Nano (~4K tokens ≈ 10K chars for context + prompt). */
|
||||
private const val MAX_CONTEXT_CHARS = 8_000
|
||||
|
||||
/** Max chars for the current page (gets priority). */
|
||||
private const val MAX_PAGE_CHARS = 4_000
|
||||
|
||||
/** Max chars per additional page snippet. */
|
||||
private const val MAX_SNIPPET_CHARS = 2_000
|
||||
|
||||
/** Minimum useful snippet size — don't bother with tiny fragments. */
|
||||
private const val MIN_USEFUL_SNIPPET = 100
|
||||
|
||||
/** Minimum paragraph length to consider. */
|
||||
private const val MIN_PARAGRAPH_LEN = 20
|
||||
|
||||
// Scoring weights for page ranking
|
||||
private const val CONTENT_MATCH_SCORE = 3
|
||||
private const val TITLE_MATCH_SCORE = 10
|
||||
private const val KEYWORD_MATCH_SCORE = 7
|
||||
private const val ALIAS_MATCH_SCORE = 5
|
||||
|
||||
private val STOP_WORDS =
|
||||
setOf(
|
||||
"the",
|
||||
"is",
|
||||
"at",
|
||||
"in",
|
||||
"on",
|
||||
"to",
|
||||
"of",
|
||||
"an",
|
||||
"it",
|
||||
"do",
|
||||
"me",
|
||||
"my",
|
||||
"or",
|
||||
"if",
|
||||
"be",
|
||||
"as",
|
||||
"by",
|
||||
"so",
|
||||
"we",
|
||||
"he",
|
||||
"up",
|
||||
"no",
|
||||
"am",
|
||||
"us",
|
||||
)
|
||||
|
||||
private const val FALLBACK_CONTEXT =
|
||||
"Meshtastic is an open-source mesh networking platform for LoRa radios. " +
|
||||
"The app connects to Meshtastic devices via Bluetooth or WiFi to send messages, " +
|
||||
"share location, and manage mesh network settings like channels, nodes, and modules."
|
||||
|
||||
/** URLs appended to prompts for the cloud model to leverage URL context tool. Only official sources. */
|
||||
private const val MESHTASTIC_URL_HINT =
|
||||
"\n\nFor additional context, you may reference these official sources:" +
|
||||
"\n- https://meshtastic.org/docs/" +
|
||||
"\n- https://github.com/meshtastic/Meshtastic-Android" +
|
||||
"\n- https://github.com/meshtastic/firmware" +
|
||||
"\n- https://github.com/meshtastic/protobufs"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.di
|
||||
|
||||
import android.content.Context
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.app.ai.GeminiNanoDocAssistant
|
||||
import org.meshtastic.app.translation.MlKitDocTranslator
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.data.DocBundleLoader
|
||||
import org.meshtastic.feature.docs.data.KeywordSearchEngine
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationCache
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
|
||||
/** Provides the on-device Gemini Nano AI assistant for the Google flavor. */
|
||||
@Module
|
||||
class GoogleAiModule {
|
||||
@Single
|
||||
fun aiDocAssistant(searchEngine: KeywordSearchEngine, bundleLoader: DocBundleLoader): AIDocAssistant =
|
||||
GeminiNanoDocAssistant(searchEngine, bundleLoader)
|
||||
|
||||
@Single
|
||||
fun docTranslationCache(context: Context): DocTranslationCache =
|
||||
DocTranslationCache(cacheDir = context.cacheDir.toOkioPath())
|
||||
|
||||
@Single fun docTranslationService(cache: DocTranslationCache): DocTranslationService = MlKitDocTranslator(cache)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.app.translation
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.google.mlkit.common.model.DownloadConditions
|
||||
import com.google.mlkit.common.model.RemoteModelManager
|
||||
import com.google.mlkit.nl.translate.TranslateLanguage
|
||||
import com.google.mlkit.nl.translate.TranslateRemoteModel
|
||||
import com.google.mlkit.nl.translate.Translation
|
||||
import com.google.mlkit.nl.translate.TranslatorOptions
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationCache
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.DownloadResult
|
||||
import org.meshtastic.feature.docs.translation.MarkdownTranslationSegmenter
|
||||
import org.meshtastic.feature.docs.translation.TranslationResult
|
||||
import org.meshtastic.feature.docs.translation.md5Hash
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
/**
|
||||
* ML Kit-powered document translation service for the Google flavor.
|
||||
*
|
||||
* Downloads language models on-demand (~30MB each) and translates markdown content while preserving structure via
|
||||
* [MarkdownTranslationSegmenter].
|
||||
*/
|
||||
class MlKitDocTranslator(private val cache: DocTranslationCache) : DocTranslationService {
|
||||
|
||||
private val modelManager = RemoteModelManager.getInstance()
|
||||
|
||||
override suspend fun translatePage(pageId: String, markdown: String, targetLocale: String): TranslationResult {
|
||||
val sourceHash = md5Hash(markdown)
|
||||
|
||||
// Check cache first
|
||||
cache.get(pageId, targetLocale, sourceHash)?.let { cached ->
|
||||
return TranslationResult.Success(cached)
|
||||
}
|
||||
|
||||
// Check if language is supported by ML Kit
|
||||
val targetLang = TranslateLanguage.fromLanguageTag(targetLocale) ?: return TranslationResult.Unavailable
|
||||
|
||||
// Auto-download model if not present
|
||||
if (!isModelDownloaded(targetLang)) {
|
||||
Logger.i(tag = "MlKitDocTranslator") {
|
||||
"Downloading model for $targetLocale (~${ESTIMATED_MODEL_SIZE_MB}MB)"
|
||||
}
|
||||
val downloadResult = downloadLanguageModel(targetLocale)
|
||||
if (downloadResult is DownloadResult.Failed) {
|
||||
Logger.w(tag = "MlKitDocTranslator") { "Model download failed: ${downloadResult.reason}" }
|
||||
return TranslationResult.ModelDownloadRequired(targetLocale, ESTIMATED_MODEL_SIZE_MB)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform translation
|
||||
return try {
|
||||
val options =
|
||||
TranslatorOptions.Builder()
|
||||
.setSourceLanguage(TranslateLanguage.ENGLISH)
|
||||
.setTargetLanguage(targetLang)
|
||||
.build()
|
||||
|
||||
val translator = Translation.getClient(options)
|
||||
try {
|
||||
val translated =
|
||||
MarkdownTranslationSegmenter.translateMarkdown(markdown) { text ->
|
||||
suspendCancellableCoroutine { cont ->
|
||||
translator
|
||||
.translate(text)
|
||||
.addOnSuccessListener { cont.resume(it) }
|
||||
.addOnFailureListener { e ->
|
||||
Logger.w(tag = "MlKitDocTranslator") {
|
||||
"Segment translation failed, using source: ${e.message}"
|
||||
}
|
||||
cont.resume(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
cache.put(pageId, targetLocale, sourceHash, translated)
|
||||
TranslationResult.Success(translated)
|
||||
} finally {
|
||||
translator.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Logger.w(tag = "MlKitDocTranslator") { "Translation failed for $pageId to $targetLocale: ${e.message}" }
|
||||
TranslationResult.Unavailable
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun isLanguageAvailable(locale: String): Boolean {
|
||||
val lang = TranslateLanguage.fromLanguageTag(locale) ?: return false
|
||||
return isModelDownloaded(lang)
|
||||
}
|
||||
|
||||
override suspend fun downloadLanguageModel(locale: String): DownloadResult {
|
||||
val lang =
|
||||
TranslateLanguage.fromLanguageTag(locale) ?: return DownloadResult.Failed("Unsupported language: $locale")
|
||||
|
||||
val model = TranslateRemoteModel.Builder(lang).build()
|
||||
val conditions = DownloadConditions.Builder().build()
|
||||
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
modelManager
|
||||
.download(model, conditions)
|
||||
.addOnSuccessListener { cont.resume(DownloadResult.Success) }
|
||||
.addOnFailureListener { e -> cont.resume(DownloadResult.Failed(e.message ?: "Download failed")) }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun isModelDownloaded(lang: String): Boolean = suspendCancellableCoroutine { cont ->
|
||||
val model = TranslateRemoteModel.Builder(lang).build()
|
||||
modelManager
|
||||
.isModelDownloaded(model)
|
||||
.addOnSuccessListener { cont.resume(it) }
|
||||
.addOnFailureListener { cont.resume(false) }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ESTIMATED_MODEL_SIZE_MB = 30
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -194,6 +194,11 @@ gradlePlugin {
|
||||
implementationClass = "RootConventionPlugin"
|
||||
}
|
||||
|
||||
register("docs") {
|
||||
id = "meshtastic.docs"
|
||||
implementationClass = "org.meshtastic.buildlogic.DocsTasks"
|
||||
}
|
||||
|
||||
register("publishing") {
|
||||
id = "meshtastic.publishing"
|
||||
implementationClass = "PublishingConventionPlugin"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.buildlogic
|
||||
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.file.DirectoryProperty
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputDirectory
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.Optional
|
||||
import org.gradle.api.tasks.OutputDirectory
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.kotlin.dsl.register
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Registers docs generation, validation, and publishing tasks.
|
||||
*
|
||||
* Tasks:
|
||||
* - generateDocsBundle: Converts markdown to HTML + index.json
|
||||
* - validateDocsBundle: Schema, size, and asset validation
|
||||
* - publishDocsSite: Generates _site/ artifact for Pages
|
||||
*/
|
||||
class DocsTasks : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
val docsDir = project.rootProject.layout.projectDirectory.dir("docs")
|
||||
val outputDir = project.layout.buildDirectory.dir("generated/docs")
|
||||
|
||||
project.tasks.register<GenerateDocsBundleTask>("generateDocsBundle") {
|
||||
group = "documentation"
|
||||
description = "Generate packaged docs artifacts and keyword index from markdown source."
|
||||
sourceDir.set(docsDir)
|
||||
generatedOutputDir.set(outputDir.map { it.dir("common") })
|
||||
channel.set(project.providers.gradleProperty("docs.channel").orElse("beta"))
|
||||
version.set(project.providers.gradleProperty("docs.version").orElse("beta"))
|
||||
}
|
||||
|
||||
project.tasks.register<ValidateDocsBundleTask>("validateDocsBundle") {
|
||||
group = "documentation"
|
||||
description = "Validate keyword index schema, bundle size, and asset references."
|
||||
dependsOn("generateDocsBundle")
|
||||
bundleDir.set(outputDir.map { it.dir("common") })
|
||||
schemaFile.set(
|
||||
project.rootProject.layout.projectDirectory
|
||||
.file("specs/003-app-docs-markdown/contracts/keyword-index-schema.json")
|
||||
)
|
||||
}
|
||||
|
||||
project.tasks.register<PublishDocsSiteTask>("publishDocsSite") {
|
||||
group = "documentation"
|
||||
description = "Assemble the final Pages artifact from generated docs."
|
||||
dependsOn("generateDocsBundle")
|
||||
sourceDir.set(docsDir)
|
||||
bundleDir.set(outputDir.map { it.dir("common") })
|
||||
siteOutputDir.set(project.layout.buildDirectory.dir("_site"))
|
||||
channel.set(project.providers.gradleProperty("docs.channel").orElse("beta"))
|
||||
version.set(project.providers.gradleProperty("docs.version").orElse("beta"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class GenerateDocsBundleTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
abstract val sourceDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val generatedOutputDir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val channel: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val version: Property<String>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val src = sourceDir.get().asFile
|
||||
val out = generatedOutputDir.get().asFile
|
||||
out.mkdirs()
|
||||
|
||||
val indexEntries = mutableListOf<String>()
|
||||
var pageCount = 0
|
||||
|
||||
// Process English user and developer directories
|
||||
listOf("user", "developer").forEach { section ->
|
||||
val sectionDir = File(src, section)
|
||||
if (!sectionDir.exists()) return@forEach
|
||||
|
||||
sectionDir.listFiles { f -> f.extension == "md" }?.sortedBy { it.name }?.forEach { mdFile ->
|
||||
val frontmatter = parseFrontmatter(mdFile)
|
||||
val id = mdFile.nameWithoutExtension
|
||||
val title = frontmatter["title"] ?: id.replace("-", " ").replaceFirstChar { it.uppercase() }
|
||||
val navOrder = frontmatter["nav_order"]?.toIntOrNull() ?: 999
|
||||
val aliases = parseListField(frontmatter["aliases_raw"] ?: "")
|
||||
val keywords = extractKeywords(mdFile, title)
|
||||
val charCount = mdFile.readText().length
|
||||
|
||||
// Generate simple HTML wrapper
|
||||
val htmlDir = File(out, "docs/$section")
|
||||
htmlDir.mkdirs()
|
||||
val htmlFile = File(htmlDir, "$id.html")
|
||||
htmlFile.writeText(generateHtml(mdFile, title, "en"))
|
||||
|
||||
// Build index entry
|
||||
val keywordsJson = keywords.joinToString(", ") { "\"$it\"" }
|
||||
val aliasesJson = aliases.joinToString(", ") { "\"$it\"" }
|
||||
indexEntries.add("""
|
||||
| {
|
||||
| "id": "$id",
|
||||
| "title": "$title",
|
||||
| "section": "$section",
|
||||
| "locale": "en",
|
||||
| "resourcePath": "docs/$section/$id.html",
|
||||
| "navOrder": $navOrder,
|
||||
| "keywords": [$keywordsJson],
|
||||
| "aliases": [$aliasesJson],
|
||||
| "charCount": $charCount
|
||||
| }
|
||||
""".trimMargin())
|
||||
|
||||
pageCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Process Crowdin locale directories: docs/{qualifier}/user/*.md
|
||||
// Crowdin %android_code% produces: fr, pt-rBR, zh-rCN, zh-rTW
|
||||
val localePattern = Regex("^[a-z]{2,3}(-r[A-Z]{2})?$")
|
||||
src.listFiles { f -> f.isDirectory && localePattern.matches(f.name) }
|
||||
?.sortedBy { it.name }
|
||||
?.forEach { localeDir ->
|
||||
val locale = localeDir.name
|
||||
listOf("user").forEach { section ->
|
||||
val localeSectionDir = File(localeDir, section)
|
||||
if (!localeSectionDir.exists()) return@forEach
|
||||
|
||||
localeSectionDir.listFiles { f -> f.extension == "md" }?.sortedBy { it.name }?.forEach { mdFile ->
|
||||
val frontmatter = parseFrontmatter(mdFile)
|
||||
val id = mdFile.nameWithoutExtension
|
||||
val title = frontmatter["title"]
|
||||
?: id.replace("-", " ").replaceFirstChar { it.uppercase() }
|
||||
val navOrder = frontmatter["nav_order"]?.toIntOrNull() ?: 999
|
||||
val keywords = extractKeywords(mdFile, title)
|
||||
val charCount = mdFile.readText().length
|
||||
|
||||
// Generate locale-qualified HTML
|
||||
val htmlDir = File(out, "docs/$locale/$section")
|
||||
htmlDir.mkdirs()
|
||||
val htmlFile = File(htmlDir, "$id.html")
|
||||
htmlFile.writeText(generateHtml(mdFile, title, locale))
|
||||
|
||||
// Build locale index entry
|
||||
val keywordsJson = keywords.joinToString(", ") { "\"$it\"" }
|
||||
indexEntries.add("""
|
||||
| {
|
||||
| "id": "$id",
|
||||
| "title": "$title",
|
||||
| "section": "$section",
|
||||
| "locale": "$locale",
|
||||
| "resourcePath": "docs/$locale/$section/$id.html",
|
||||
| "navOrder": $navOrder,
|
||||
| "keywords": [$keywordsJson],
|
||||
| "aliases": [],
|
||||
| "charCount": $charCount
|
||||
| }
|
||||
""".trimMargin())
|
||||
|
||||
pageCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write index.json
|
||||
val indexFile = File(out, "index.json")
|
||||
indexFile.writeText("[\n${indexEntries.joinToString(",\n")}\n]")
|
||||
|
||||
// Write shared CSS
|
||||
val cssDir = File(out, "docs/styles")
|
||||
cssDir.mkdirs()
|
||||
File(cssDir, "docs.css").writeText(generateCss())
|
||||
|
||||
// Write locales manifest (for consumers that need to know available translations)
|
||||
val localesManifest = src.listFiles { f -> f.isDirectory && localePattern.matches(f.name) }
|
||||
?.map { it.name }?.sorted() ?: emptyList()
|
||||
val manifestFile = File(out, "locales.json")
|
||||
manifestFile.writeText(localesManifest.joinToString(", ", "[", "]") { "\"$it\"" })
|
||||
|
||||
logger.lifecycle("Generated docs bundle: $pageCount pages (${localesManifest.size} locales), channel=${channel.get()}, version=${version.get()}")
|
||||
}
|
||||
|
||||
private fun parseFrontmatter(file: File): Map<String, String> {
|
||||
val lines = file.readLines()
|
||||
if (lines.firstOrNull()?.trim() != "---") return emptyMap()
|
||||
val endIndex = lines.drop(1).indexOfFirst { it.trim() == "---" }
|
||||
if (endIndex < 0) return emptyMap()
|
||||
val fmLines = lines.subList(1, endIndex + 1)
|
||||
val result = mutableMapOf<String, String>()
|
||||
var inAliases = false
|
||||
val aliasesBuilder = StringBuilder()
|
||||
for (line in fmLines) {
|
||||
if (line.startsWith("aliases:")) {
|
||||
inAliases = true
|
||||
continue
|
||||
}
|
||||
if (inAliases) {
|
||||
if (line.startsWith(" - ")) {
|
||||
aliasesBuilder.append(line.removePrefix(" - ").trim()).append(",")
|
||||
} else {
|
||||
inAliases = false
|
||||
result["aliases_raw"] = aliasesBuilder.toString().trimEnd(',')
|
||||
}
|
||||
}
|
||||
if (!inAliases && line.contains(":")) {
|
||||
val (key, value) = line.split(":", limit = 2)
|
||||
result[key.trim()] = value.trim()
|
||||
}
|
||||
}
|
||||
if (inAliases) result["aliases_raw"] = aliasesBuilder.toString().trimEnd(',')
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseListField(raw: String): List<String> =
|
||||
raw.split(",").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
private fun extractKeywords(file: File, title: String): List<String> {
|
||||
val text = file.readText().lowercase()
|
||||
val keywords = mutableSetOf<String>()
|
||||
// Add title words
|
||||
title.lowercase().split(Regex("[^a-z0-9]+")).filter { it.length >= 3 }.forEach { keywords.add(it) }
|
||||
// Extract headings
|
||||
Regex("^#{1,3}\\s+(.+)$", RegexOption.MULTILINE).findAll(text).forEach { match ->
|
||||
match.groupValues[1].split(Regex("[^a-z0-9]+")).filter { it.length >= 3 }.forEach { keywords.add(it) }
|
||||
}
|
||||
return keywords.toList().take(30)
|
||||
}
|
||||
|
||||
private fun generateHtml(mdFile: File, title: String, locale: String = "en"): String {
|
||||
val content = mdFile.readText()
|
||||
// Strip frontmatter
|
||||
.replace(Regex("^---[\\s\\S]*?---\\s*", RegexOption.MULTILINE), "")
|
||||
.replace("&", "&").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 """
|
||||
|<!DOCTYPE html>
|
||||
|<html lang="$locale" dir="$dir">
|
||||
|<head>
|
||||
| <meta charset="UTF-8">
|
||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
| <title>$title</title>
|
||||
| <link rel="stylesheet" href="$cssPath">
|
||||
|</head>
|
||||
|<body data-page="${mdFile.nameWithoutExtension}" data-locale="$locale">
|
||||
|<pre class="markdown-content">$content</pre>
|
||||
|</body>
|
||||
|</html>
|
||||
""".trimMargin()
|
||||
}
|
||||
|
||||
private fun generateCss(): String = """
|
||||
|:root {
|
||||
| --primary: #2C2D3C;
|
||||
| --accent: #67EA94;
|
||||
| --accent-text: #3FB86D;
|
||||
| --info: #5C6BC0;
|
||||
| --warning: #E8A33E;
|
||||
| --error: #E05252;
|
||||
|}
|
||||
|body {
|
||||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
| line-height: 1.6;
|
||||
| padding: 16px;
|
||||
| color: var(--primary);
|
||||
| max-width: 800px;
|
||||
| margin: 0 auto;
|
||||
|}
|
||||
|@media (prefers-color-scheme: dark) {
|
||||
| body { background: #1A1B26; color: #ECEDF3; }
|
||||
| pre { background: #2C2D3C; }
|
||||
|}
|
||||
|pre.markdown-content {
|
||||
| white-space: pre-wrap;
|
||||
| word-wrap: break-word;
|
||||
| font-family: inherit;
|
||||
| background: transparent;
|
||||
| padding: 0;
|
||||
| margin: 0;
|
||||
|}
|
||||
|.callout-info { border-left: 4px solid var(--info); padding: 12px; background: #E8EAF6; margin: 12px 0; }
|
||||
|.callout-warning { border-left: 4px solid var(--warning); padding: 12px; background: #FFF3E0; margin: 12px 0; }
|
||||
|.callout-error { border-left: 4px solid var(--error); padding: 12px; background: #FDEAEA; margin: 12px 0; }
|
||||
""".trimMargin()
|
||||
}
|
||||
|
||||
abstract class ValidateDocsBundleTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
@get:Optional
|
||||
abstract val bundleDir: DirectoryProperty
|
||||
|
||||
@get:InputFile
|
||||
@get:Optional
|
||||
abstract val schemaFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun validate() {
|
||||
val dir = bundleDir.get().asFile
|
||||
val indexFile = File(dir, "index.json")
|
||||
|
||||
// Check index exists
|
||||
if (!indexFile.exists()) {
|
||||
throw org.gradle.api.GradleException("index.json not found in ${dir.absolutePath}")
|
||||
}
|
||||
|
||||
// Check index is valid JSON array
|
||||
val indexContent = indexFile.readText()
|
||||
if (!indexContent.trimStart().startsWith("[")) {
|
||||
throw org.gradle.api.GradleException("index.json must be a JSON array")
|
||||
}
|
||||
|
||||
// Check bundle size
|
||||
val totalSize = dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
|
||||
val sizeMb = totalSize / (1024.0 * 1024.0)
|
||||
|
||||
if (sizeMb > 10.0) {
|
||||
throw org.gradle.api.GradleException("Bundle size ${String.format("%.2f", sizeMb)} MB exceeds 10 MB hard limit")
|
||||
}
|
||||
if (sizeMb > 8.0) {
|
||||
logger.warn("WARNING: Bundle size ${String.format("%.2f", sizeMb)} MB exceeds 8 MB warning threshold")
|
||||
}
|
||||
|
||||
// Check all referenced pages exist
|
||||
val pagePattern = Regex("\"resourcePath\"\\s*:\\s*\"([^\"]+)\"")
|
||||
val referencedPaths = pagePattern.findAll(indexContent).map { it.groupValues[1] }.toList()
|
||||
val missingPages = referencedPaths.filter { !File(dir, it).exists() }
|
||||
if (missingPages.isNotEmpty()) {
|
||||
throw org.gradle.api.GradleException("Missing page files: ${missingPages.joinToString()}")
|
||||
}
|
||||
|
||||
logger.lifecycle("Docs bundle validation PASSED: ${referencedPaths.size} pages, ${String.format("%.2f", sizeMb)} MB")
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PublishDocsSiteTask : DefaultTask() {
|
||||
@get:InputDirectory
|
||||
abstract val sourceDir: DirectoryProperty
|
||||
|
||||
@get:InputDirectory
|
||||
abstract val bundleDir: DirectoryProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val siteOutputDir: DirectoryProperty
|
||||
|
||||
@get:Input
|
||||
abstract val channel: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val version: Property<String>
|
||||
|
||||
@TaskAction
|
||||
fun publish() {
|
||||
val siteDir = siteOutputDir.get().asFile
|
||||
val channelPath = if (channel.get() == "release") "v${version.get()}" else channel.get()
|
||||
val outDir = File(siteDir, channelPath)
|
||||
outDir.mkdirs()
|
||||
|
||||
// Copy generated bundle to site output
|
||||
val bundle = bundleDir.get().asFile
|
||||
bundle.copyRecursively(outDir, overwrite = true)
|
||||
|
||||
// Copy source markdown for Jekyll processing
|
||||
val src = sourceDir.get().asFile
|
||||
src.listFiles()?.filter { it.name != "_site" && it.name != ".jekyll-cache" }?.forEach { f ->
|
||||
if (f.isDirectory) f.copyRecursively(File(outDir, f.name), overwrite = true)
|
||||
else f.copyTo(File(outDir, f.name), overwrite = true)
|
||||
}
|
||||
|
||||
logger.lifecycle("Published docs site to: ${outDir.absolutePath} (channel=$channelPath)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,9 @@ package org.meshtastic.core.database
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -27,6 +30,7 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.meshtastic.core.common.ContextServices
|
||||
import org.meshtastic.core.di.CoroutineDispatchers
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@@ -34,10 +38,13 @@ class DatabaseManagerLegacyCleanupTest {
|
||||
@Test
|
||||
fun deletes_legacy_db_on_switch_when_flag_not_set() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<Application>()
|
||||
val prefs = app.getSharedPreferences("db-manager-prefs", Context.MODE_PRIVATE)
|
||||
ContextServices.app = app
|
||||
val datastoreName = "db-manager-prefs-test-${System.nanoTime()}"
|
||||
val datastore = createDatabaseDataStore(datastoreName)
|
||||
|
||||
// Reset the one-time flag
|
||||
prefs.edit().remove(DatabaseConstants.LEGACY_DB_CLEANED_KEY).apply()
|
||||
val legacyCleanedKey = booleanPreferencesKey(DatabaseConstants.LEGACY_DB_CLEANED_KEY)
|
||||
datastore.edit { it.remove(legacyCleanedKey) }
|
||||
|
||||
// Ensure legacy DB file exists
|
||||
val legacyName = DatabaseConstants.LEGACY_DB_NAME
|
||||
@@ -48,7 +55,7 @@ class DatabaseManagerLegacyCleanupTest {
|
||||
|
||||
val testDispatchers =
|
||||
CoroutineDispatchers(io = Dispatchers.IO, main = Dispatchers.Main, default = Dispatchers.Default)
|
||||
val manager = DatabaseManager(app, testDispatchers)
|
||||
val manager = DatabaseManager(datastore, testDispatchers)
|
||||
|
||||
// Switch to a non-null address so active DB != legacy
|
||||
manager.switchActiveDatabase("01:23:45:67:89:AB")
|
||||
@@ -61,5 +68,8 @@ class DatabaseManagerLegacyCleanupTest {
|
||||
}
|
||||
|
||||
assertFalse("Legacy DB should be deleted after switch", legacyFile.exists())
|
||||
|
||||
// Clean up
|
||||
app.preferencesDataStoreFile(datastoreName).delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ object DeepLinkRouter {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ReturnCount", "MagicNumber")
|
||||
private fun routeSettings(segments: List<String>): List<NavKey> {
|
||||
var destNum: Int? = null
|
||||
var subRouteStr: String? = null
|
||||
@@ -153,6 +154,17 @@ object DeepLinkRouter {
|
||||
return listOf(SettingsRoute.Settings(destNum))
|
||||
}
|
||||
|
||||
// Handle helpDocs/{pageId} pattern
|
||||
if (subRouteStr == "helpdocs" || subRouteStr == "help-docs") {
|
||||
val pageIdSegmentIndex = if (destNum != null) 3 else 2
|
||||
return if (segments.size > pageIdSegmentIndex) {
|
||||
val pageId = segments[pageIdSegmentIndex]
|
||||
listOf(SettingsRoute.Settings(destNum), SettingsRoute.HelpDocs, SettingsRoute.HelpDocPage(pageId))
|
||||
} else {
|
||||
listOf(SettingsRoute.Settings(destNum), SettingsRoute.HelpDocs)
|
||||
}
|
||||
}
|
||||
|
||||
val subRoute = settingsSubRoutes[subRouteStr]
|
||||
return if (subRoute != null) {
|
||||
listOf(SettingsRoute.Settings(destNum), subRoute)
|
||||
@@ -210,6 +222,8 @@ object DeepLinkRouter {
|
||||
"debug-panel" to SettingsRoute.DebugPanel,
|
||||
"about" to SettingsRoute.About,
|
||||
"filter-settings" to SettingsRoute.FilterSettings,
|
||||
"helpdocs" to SettingsRoute.HelpDocs,
|
||||
"help-docs" to SettingsRoute.HelpDocs,
|
||||
)
|
||||
|
||||
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -326,6 +326,47 @@
|
||||
<string name="distance_measurements">Distance Measurements</string>
|
||||
<string name="distance_measurements_description">Display the distance between your phone and other Meshtastic nodes with positions.</string>
|
||||
<string name="dns">DNS</string>
|
||||
<!-- DOC -->
|
||||
<string name="doc_clear_search">Clear search</string>
|
||||
<string name="doc_keywords_connections">bluetooth,usb,tcp,pairing,serial,wifi</string>
|
||||
<string name="doc_keywords_desktop">desktop,linux,macos,windows,serial</string>
|
||||
<string name="doc_keywords_discovery">discovery,topology,network,scan,neighbor</string>
|
||||
<string name="doc_keywords_firmware">firmware,update,ota,flash,version,recovery</string>
|
||||
<string name="doc_keywords_map">map,waypoint,gps,position,location,marker</string>
|
||||
<string name="doc_keywords_measurement">formatter,metric,number,locale,temperature,conversion,api</string>
|
||||
<string name="doc_keywords_messages">message,channel,encryption,direct,broadcast,quick-chat</string>
|
||||
<string name="doc_keywords_mqtt">mqtt,broker,internet,bridge,uplink,downlink</string>
|
||||
<string name="doc_keywords_node_metrics">metrics,telemetry,signal,snr,rssi,battery,traceroute</string>
|
||||
<string name="doc_keywords_nodes">node,mesh,list,role,status,favorite,filter</string>
|
||||
<string name="doc_keywords_onboarding">setup,welcome,permissions,first-launch</string>
|
||||
<string name="doc_keywords_settings_module">module,serial,telemetry,canned,store-forward,administration</string>
|
||||
<string name="doc_keywords_settings_radio">settings,radio,lora,region,modem,device,power,security</string>
|
||||
<string name="doc_keywords_signal_meter">signal,rssi,snr,bars,quality,lora,noise,meter</string>
|
||||
<string name="doc_keywords_tak">tak,atak,cursor-on-target,team-awareness</string>
|
||||
<string name="doc_keywords_telemetry">telemetry,sensor,temperature,humidity,pressure,power</string>
|
||||
<string name="doc_keywords_translate">translate,crowdin,localization,language,i18n,contribute</string>
|
||||
<string name="doc_keywords_units">units,locale,metric,imperial,temperature,distance</string>
|
||||
<string name="doc_search_placeholder">Search documentation…</string>
|
||||
<string name="doc_section_developer">Developer Guide</string>
|
||||
<string name="doc_section_user">User Guide</string>
|
||||
<string name="doc_title_connections">Connections</string>
|
||||
<string name="doc_title_desktop">Desktop App</string>
|
||||
<string name="doc_title_discovery">Discovery</string>
|
||||
<string name="doc_title_firmware">Firmware Updates</string>
|
||||
<string name="doc_title_map">Map & Waypoints</string>
|
||||
<string name="doc_title_measurement">Measurement & Formatting</string>
|
||||
<string name="doc_title_messages">Messages & Channels</string>
|
||||
<string name="doc_title_mqtt">MQTT</string>
|
||||
<string name="doc_title_node_metrics">Node Metrics</string>
|
||||
<string name="doc_title_nodes">Nodes</string>
|
||||
<string name="doc_title_onboarding">Getting Started</string>
|
||||
<string name="doc_title_settings_module">Settings — Modules & Admin</string>
|
||||
<string name="doc_title_settings_radio">Settings — Radio & User</string>
|
||||
<string name="doc_title_signal_meter">Signal Meter</string>
|
||||
<string name="doc_title_tak">TAK Integration</string>
|
||||
<string name="doc_title_telemetry">Telemetry & Sensors</string>
|
||||
<string name="doc_title_translate">Translate the App</string>
|
||||
<string name="doc_title_units">Units & Locale</string>
|
||||
<string name="done">Done</string>
|
||||
<string name="dont_show_again_for_device">Don't show again for this device</string>
|
||||
<string name="double_tap_as_button_press">Double Tap as Button</string>
|
||||
@@ -507,6 +548,7 @@
|
||||
<string name="hardware_model">Hardware model</string>
|
||||
<string name="heading">Heading</string>
|
||||
<string name="heartbeat">Heartbeat</string>
|
||||
<string name="help_and_documentation">Help & Documentation</string>
|
||||
<string name="hide_layer">Hide Layer</string>
|
||||
<string name="hide_password">Hide password</string>
|
||||
<string name="history_return_max">History return max</string>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -78,6 +78,10 @@ import org.meshtastic.desktop.stub.NoopMeshWorkerManager
|
||||
import org.meshtastic.desktop.stub.NoopPhoneLocationProvider
|
||||
import org.meshtastic.desktop.stub.NoopPlatformAnalytics
|
||||
import org.meshtastic.desktop.stub.NoopServiceBroadcasts
|
||||
import org.meshtastic.feature.docs.ai.AIDocAssistant
|
||||
import org.meshtastic.feature.docs.ai.KeywordFallbackAssistant
|
||||
import org.meshtastic.feature.docs.translation.DocTranslationService
|
||||
import org.meshtastic.feature.docs.translation.NoOpDocTranslator
|
||||
import org.meshtastic.feature.node.compass.CompassHeadingProvider
|
||||
import org.meshtastic.feature.node.compass.MagneticFieldProvider
|
||||
import org.meshtastic.feature.node.compass.PhoneLocationProvider
|
||||
@@ -96,6 +100,7 @@ import org.meshtastic.core.takserver.di.module as coreTakServerModule
|
||||
import org.meshtastic.core.ui.di.module as coreUiModule
|
||||
import org.meshtastic.desktop.di.module as desktopDiModule
|
||||
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
|
||||
import org.meshtastic.feature.docs.di.module as featureDocsModule
|
||||
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
|
||||
import org.meshtastic.feature.intro.di.module as featureIntroModule
|
||||
import org.meshtastic.feature.map.di.module as featureMapModule
|
||||
@@ -137,6 +142,7 @@ fun desktopModule() = module {
|
||||
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
|
||||
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
|
||||
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
|
||||
org.meshtastic.feature.docs.di.FeatureDocsModule().featureDocsModule(),
|
||||
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
|
||||
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),
|
||||
org.meshtastic.desktop.di.DesktopDiModule().desktopDiModule(),
|
||||
@@ -192,6 +198,10 @@ private fun desktopPlatformStubsModule() = module {
|
||||
single<PhoneLocationProvider> { NoopPhoneLocationProvider() }
|
||||
single<MagneticFieldProvider> { NoopMagneticFieldProvider() }
|
||||
|
||||
// AI assistant: keyword-only fallback on desktop (no on-device model)
|
||||
single<AIDocAssistant> { get<KeywordFallbackAssistant>() }
|
||||
single<DocTranslationService> { NoOpDocTranslator() }
|
||||
|
||||
// Desktop uses the real ApiService implementation (no flavor stub needed)
|
||||
single<ApiService> { ApiServiceImpl(client = get()) }
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.meshtastic.core.navigation.MultiBackstack
|
||||
import org.meshtastic.core.navigation.TopLevelDestination
|
||||
import org.meshtastic.core.ui.viewmodel.UIViewModel
|
||||
import org.meshtastic.feature.connections.navigation.connectionsGraph
|
||||
import org.meshtastic.feature.docs.navigation.docsEntries
|
||||
import org.meshtastic.feature.firmware.navigation.firmwareGraph
|
||||
import org.meshtastic.feature.map.navigation.mapGraph
|
||||
import org.meshtastic.feature.messaging.navigation.contactsGraph
|
||||
@@ -52,6 +53,7 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(
|
||||
mapGraph(backStack)
|
||||
firmwareGraph(backStack)
|
||||
settingsGraph(backStack)
|
||||
docsEntries(backStack)
|
||||
channelsGraph(backStack)
|
||||
connectionsGraph(backStack)
|
||||
wifiProvisionGraph(backStack)
|
||||
|
||||
8
docs/Gemfile
Normal file
@@ -0,0 +1,8 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
gem "jekyll", "~> 4.3"
|
||||
gem "just-the-docs"
|
||||
gem "jekyll-redirect-from"
|
||||
gem "jekyll-remote-theme"
|
||||
gem "csv"
|
||||
gem "webrick"
|
||||
289
docs/_config.yml
Normal file
@@ -0,0 +1,289 @@
|
||||
title: Meshtastic Android
|
||||
description: "User and developer documentation for the Meshtastic Android, Desktop, and iOS applications."
|
||||
baseurl: ""
|
||||
url: ""
|
||||
|
||||
# just-the-docs theme
|
||||
# Local builds use `gem "just-the-docs"` from Gemfile.
|
||||
# GitHub Pages uses remote_theme for hosted builds.
|
||||
theme: just-the-docs
|
||||
remote_theme: just-the-docs/just-the-docs@v0.11.0
|
||||
|
||||
# Plugins
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
- jekyll-redirect-from
|
||||
|
||||
# Navigation
|
||||
nav_enabled: true
|
||||
search_enabled: true
|
||||
search_tokenizer_separator: /[\s\-/]+/
|
||||
|
||||
# Heading anchors (clickable § links)
|
||||
heading_anchors: true
|
||||
|
||||
# Color scheme — loads _sass/color_schemes/meshtastic.scss
|
||||
color_scheme: meshtastic
|
||||
|
||||
# Default front-matter for pages in subdirectories
|
||||
defaults:
|
||||
- scope:
|
||||
path: "user"
|
||||
values:
|
||||
parent: User Guide
|
||||
layout: default
|
||||
- scope:
|
||||
path: "developer"
|
||||
values:
|
||||
parent: Developer Guide
|
||||
layout: default
|
||||
# Locale-translated pages are excluded from main nav but still rendered.
|
||||
# They use a dedicated locale layout with a back-link to the English version.
|
||||
# Auto-generated from Android app locales (values-* resource dirs).
|
||||
- scope:
|
||||
path: "ar"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ar
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "be"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: be
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "bg"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: bg
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ca"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ca
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "cs"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: cs
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "de"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: de
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "el"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: el
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "es"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: es
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "et"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: et
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "fi"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: fi
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "fr"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: fr
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ga"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ga
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "gl"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: gl
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "he"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: he
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "hr"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: hr
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ht"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ht
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "hu"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: hu
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "is"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: is
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "it"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: it
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ja"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ja
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ko"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ko
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "lt"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: lt
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "nl"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: nl
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "no"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: no
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "pl"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: pl
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "pt"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: pt
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "pt-rBR"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: pt-rBR
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ro"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ro
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "ru"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: ru
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "sk"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: sk
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "sl"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: sl
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "sq"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: sq
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "sr"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: sr
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "sv"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: sv
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "tr"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: tr
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "uk"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: uk
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "zh-rCN"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: zh-rCN
|
||||
nav_exclude: true
|
||||
- scope:
|
||||
path: "zh-rTW"
|
||||
values:
|
||||
layout: locale_page
|
||||
locale: zh-rTW
|
||||
nav_exclude: true
|
||||
|
||||
# Callouts (just-the-docs v0.11+)
|
||||
callouts:
|
||||
tip:
|
||||
title: Tip
|
||||
color: green
|
||||
note:
|
||||
title: Note
|
||||
color: blue
|
||||
warning:
|
||||
title: Warning
|
||||
color: yellow
|
||||
|
||||
exclude:
|
||||
- Gemfile
|
||||
- Gemfile.lock
|
||||
- assets/screenshots/.gitkeep
|
||||
- "*.sh"
|
||||
|
||||
119
docs/_data/locales.yml
Normal file
@@ -0,0 +1,119 @@
|
||||
# Supported documentation locales.
|
||||
# Each entry maps a Crowdin 2-letter code to its native name.
|
||||
# Pages land at docs/{code}/user/*.md via Crowdin sync.
|
||||
# Synced with Android app locales (values-* resource dirs).
|
||||
|
||||
ar:
|
||||
name: "العربية"
|
||||
dir: rtl
|
||||
be:
|
||||
name: "Беларуская"
|
||||
dir: ltr
|
||||
bg:
|
||||
name: "Български"
|
||||
dir: ltr
|
||||
ca:
|
||||
name: "Català"
|
||||
dir: ltr
|
||||
cs:
|
||||
name: "Čeština"
|
||||
dir: ltr
|
||||
de:
|
||||
name: "Deutsch"
|
||||
dir: ltr
|
||||
el:
|
||||
name: "Ελληνικά"
|
||||
dir: ltr
|
||||
es:
|
||||
name: "Español"
|
||||
dir: ltr
|
||||
et:
|
||||
name: "Eesti"
|
||||
dir: ltr
|
||||
fi:
|
||||
name: "Suomi"
|
||||
dir: ltr
|
||||
fr:
|
||||
name: "Français"
|
||||
dir: ltr
|
||||
ga:
|
||||
name: "Gaeilge"
|
||||
dir: ltr
|
||||
gl:
|
||||
name: "Galego"
|
||||
dir: ltr
|
||||
he:
|
||||
name: "עברית"
|
||||
dir: rtl
|
||||
hr:
|
||||
name: "Hrvatski"
|
||||
dir: ltr
|
||||
ht:
|
||||
name: "Kreyòl Ayisyen"
|
||||
dir: ltr
|
||||
hu:
|
||||
name: "Magyar"
|
||||
dir: ltr
|
||||
is:
|
||||
name: "Íslenska"
|
||||
dir: ltr
|
||||
it:
|
||||
name: "Italiano"
|
||||
dir: ltr
|
||||
ja:
|
||||
name: "日本語"
|
||||
dir: ltr
|
||||
ko:
|
||||
name: "한국어"
|
||||
dir: ltr
|
||||
lt:
|
||||
name: "Lietuvių"
|
||||
dir: ltr
|
||||
nl:
|
||||
name: "Nederlands"
|
||||
dir: ltr
|
||||
"no":
|
||||
name: "Norsk"
|
||||
dir: ltr
|
||||
pl:
|
||||
name: "Polski"
|
||||
dir: ltr
|
||||
pt:
|
||||
name: "Português"
|
||||
dir: ltr
|
||||
pt-rBR:
|
||||
name: "Português (Brasil)"
|
||||
dir: ltr
|
||||
ro:
|
||||
name: "Română"
|
||||
dir: ltr
|
||||
ru:
|
||||
name: "Русский"
|
||||
dir: ltr
|
||||
sk:
|
||||
name: "Slovenčina"
|
||||
dir: ltr
|
||||
sl:
|
||||
name: "Slovenščina"
|
||||
dir: ltr
|
||||
sq:
|
||||
name: "Shqip"
|
||||
dir: ltr
|
||||
sr:
|
||||
name: "Српски"
|
||||
dir: ltr
|
||||
sv:
|
||||
name: "Svenska"
|
||||
dir: ltr
|
||||
tr:
|
||||
name: "Türkçe"
|
||||
dir: ltr
|
||||
uk:
|
||||
name: "Українська"
|
||||
dir: ltr
|
||||
zh-rCN:
|
||||
name: "中文 (简体)"
|
||||
dir: ltr
|
||||
zh-rTW:
|
||||
name: "中文 (繁體)"
|
||||
dir: ltr
|
||||
12
docs/_data/versions.yml
Normal file
@@ -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
|
||||
|
||||
4
docs/_includes/footer_custom.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<footer class="site-footer">
|
||||
Copyright © {{ "now" | date: "%Y" }} Meshtastic LLC. Distributed under the <a href="https://www.gnu.org/licenses/gpl-3.0.html">GPL v3 License.</a>
|
||||
</footer>
|
||||
|
||||
166
docs/_includes/head_custom.html
Normal file
@@ -0,0 +1,166 @@
|
||||
<!-- Inter font from Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
/* M3-inspired typography */
|
||||
:root {
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
body,
|
||||
.site-title,
|
||||
.search-input {
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
code, pre, .highlight {
|
||||
font-family: var(--font-mono) !important;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/* M3 surface elevation & rounding */
|
||||
.main-content table {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-content blockquote {
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.main-content pre {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.main-content code {
|
||||
border-radius: 6px;
|
||||
padding: 0.15em 0.4em;
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme switching */
|
||||
body,
|
||||
.side-bar,
|
||||
.main,
|
||||
.main-content,
|
||||
.site-header,
|
||||
.search-input,
|
||||
.search-results,
|
||||
a {
|
||||
transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Theme toggle button styling */
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color, #D5D6E0);
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: inherit;
|
||||
margin: 0 auto;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.theme-toggle-wrap {
|
||||
text-align: center;
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
/* Heading weight refinement */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h1 { font-weight: 700; letter-spacing: -0.02em; }
|
||||
|
||||
/* Smooth anchor scroll */
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
/* Language switcher */
|
||||
.language-switcher {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.language-switcher-btn {
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
list-style: none;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border-color, #D5D6E0);
|
||||
border-radius: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.language-switcher-btn:hover {
|
||||
background-color: rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.language-switcher[open] .language-switcher-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.language-switcher-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
list-style: none;
|
||||
padding: 8px 0;
|
||||
margin: 4px 0 0;
|
||||
min-width: 140px;
|
||||
background: var(--body-background-color, #fff);
|
||||
border: 1px solid var(--border-color, #D5D6E0);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.language-switcher-list li {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.language-switcher-list a {
|
||||
display: block;
|
||||
padding: 6px 16px;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.language-switcher-list a:hover {
|
||||
background-color: rgba(103, 234, 148, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Respect OS preference on first visit, then remember user choice
|
||||
(function() {
|
||||
var stored = localStorage.getItem('jtd-theme');
|
||||
if (stored) return; // will be applied by jtd.setTheme below
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
localStorage.setItem('jtd-theme', 'meshtastic-dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
45
docs/_includes/header_custom.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<div class="theme-toggle-wrap">
|
||||
<button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark mode" title="Toggle light/dark theme" onclick="toggleMeshtasticTheme()">
|
||||
<span id="theme-icon">🌙</span>
|
||||
<span id="theme-label">Dark</span>
|
||||
</button>
|
||||
{% include language_switcher.html %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleMeshtasticTheme() {
|
||||
var current = localStorage.getItem('jtd-theme') || 'meshtastic';
|
||||
var next = (current === 'meshtastic-dark') ? 'meshtastic' : 'meshtastic-dark';
|
||||
if (typeof jtd !== 'undefined' && typeof jtd.setTheme === 'function') {
|
||||
jtd.setTheme(next);
|
||||
}
|
||||
localStorage.setItem('jtd-theme', next);
|
||||
var icon = document.getElementById('theme-icon');
|
||||
var label = document.getElementById('theme-label');
|
||||
if (icon && label) {
|
||||
icon.textContent = (next === 'meshtastic-dark') ? '☀️' : '🌙';
|
||||
label.textContent = (next === 'meshtastic-dark') ? 'Light' : 'Dark';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply stored/OS theme on page load
|
||||
(function() {
|
||||
var theme = localStorage.getItem('jtd-theme') || 'meshtastic';
|
||||
// Sync toggle label immediately
|
||||
var icon = document.getElementById('theme-icon');
|
||||
var label = document.getElementById('theme-label');
|
||||
if (icon && label) {
|
||||
icon.textContent = (theme === 'meshtastic-dark') ? '☀️' : '🌙';
|
||||
label.textContent = (theme === 'meshtastic-dark') ? 'Light' : 'Dark';
|
||||
}
|
||||
// Apply theme once jtd is ready
|
||||
function tryApply() {
|
||||
if (typeof jtd !== 'undefined' && typeof jtd.setTheme === 'function') {
|
||||
jtd.setTheme(theme);
|
||||
} else {
|
||||
setTimeout(tryApply, 50);
|
||||
}
|
||||
}
|
||||
tryApply();
|
||||
})();
|
||||
</script>
|
||||
71
docs/_includes/language_switcher.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{% comment %}
|
||||
Language switcher for translated docs pages.
|
||||
Renders a dropdown-style link list showing available translations of the current page.
|
||||
Include this in any page or layout that should offer locale switching.
|
||||
|
||||
Usage: {% include language_switcher.html %}
|
||||
|
||||
Logic:
|
||||
- Derives the current page's relative path within its section
|
||||
- Checks if translated versions exist in locale subdirectories
|
||||
- Shows a globe icon with available locale links
|
||||
{% endcomment %}
|
||||
|
||||
{% assign current_path = page.path %}
|
||||
{% assign locales = site.data.locales %}
|
||||
|
||||
{% if locales and current_path %}
|
||||
<details class="language-switcher" aria-label="Language options">
|
||||
<summary class="language-switcher-btn" title="View in another language">
|
||||
🌐 <span class="lang-current">English</span>
|
||||
</summary>
|
||||
<ul class="language-switcher-list">
|
||||
{% comment %} Always show English link back to source {% endcomment %}
|
||||
{% assign path_parts = current_path | split: "/" %}
|
||||
{% assign first_segment = path_parts[0] %}
|
||||
|
||||
{% comment %} Detect if we're currently IN a locale subdir {% endcomment %}
|
||||
{% if locales[first_segment] %}
|
||||
{% comment %} We're on a translated page — link back to English {% endcomment %}
|
||||
{% assign remaining_parts = path_parts | slice: 1, path_parts.size %}
|
||||
{% assign en_path = remaining_parts | join: "/" | replace: ".md", "" %}
|
||||
<li><a href="{{ en_path | relative_url }}" lang="en">English</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} Show all available locale versions {% endcomment %}
|
||||
{% for locale in locales %}
|
||||
{% assign locale_code = locale[0] %}
|
||||
{% assign locale_info = locale[1] %}
|
||||
|
||||
{% if locales[first_segment] %}
|
||||
{% comment %} We're already on a translated page {% endcomment %}
|
||||
{% if locale_code == first_segment %}
|
||||
{% continue %}
|
||||
{% endif %}
|
||||
{% assign locale_path = locale_code | append: "/" | append: en_path %}
|
||||
{% else %}
|
||||
{% comment %} We're on an English page {% endcomment %}
|
||||
{% assign en_relative = current_path | replace: ".md", "" %}
|
||||
{% assign locale_path = locale_code | append: "/" | append: en_relative %}
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
Check if the translated file actually exists.
|
||||
Jekyll doesn't have a file_exists filter, so we check site.pages.
|
||||
{% endcomment %}
|
||||
{% assign locale_file = locale_path | append: ".md" %}
|
||||
{% assign page_exists = false %}
|
||||
{% for p in site.pages %}
|
||||
{% if p.path == locale_file %}
|
||||
{% assign page_exists = true %}
|
||||
{% break %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page_exists %}
|
||||
<li><a href="{{ locale_path | relative_url }}" lang="{{ locale_code }}" {% if locale_info.dir == "rtl" %}dir="rtl"{% endif %}>{{ locale_info.name }}</a></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
36
docs/_layouts/locale_page.html
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
{% assign locale_code = page.locale | default: "en" %}
|
||||
{% assign locale_info = site.data.locales[locale_code] %}
|
||||
{% assign page_path = page.path %}
|
||||
{% assign path_parts = page_path | split: "/" %}
|
||||
{% assign remaining_parts = path_parts | slice: 1, path_parts.size %}
|
||||
{% assign en_path = remaining_parts | join: "/" | replace: ".md", "" %}
|
||||
|
||||
<div class="locale-page-banner" {% if locale_info.dir == "rtl" %}dir="rtl"{% endif %}>
|
||||
<p class="locale-notice">
|
||||
🌐 <strong>{{ locale_info.name }}</strong> — Community translation
|
||||
<a href="{{ en_path | relative_url }}">View in English</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div {% if locale_info.dir == "rtl" %}dir="rtl" lang="{{ locale_code }}"{% else %}lang="{{ locale_code }}"{% endif %}>
|
||||
{{ content }}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.locale-page-banner {
|
||||
background: rgba(103, 234, 148, 0.1);
|
||||
border: 1px solid rgba(103, 234, 148, 0.3);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.locale-notice a {
|
||||
margin-left: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
40
docs/_sass/color_schemes/meshtastic-dark.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
// Meshtastic Material 3 — Dark color scheme for just-the-docs
|
||||
// Source: core/ui/.../Color.kt §8.3 (Dark Scheme)
|
||||
//
|
||||
// Brand colors:
|
||||
// Primary (Dark): #67EA94 (Green 500)
|
||||
// Surface (Dark): #1A1B26 (Neutral 900)
|
||||
|
||||
$color-scheme: dark;
|
||||
|
||||
// Body / page background
|
||||
$body-background-color: #1A1B26; // Neutral 900 — surfaceDark
|
||||
$sidebar-color: #1A1B26; // Neutral 900
|
||||
$body-text-color: #ECEDF3; // Neutral 100 — onSurfaceDark
|
||||
$body-heading-color: #ECEDF3; // Neutral 100
|
||||
|
||||
// Links
|
||||
$link-color: #67EA94; // Green 500 — primaryDark
|
||||
$nav-link-color: #67EA94; // Green 500
|
||||
|
||||
// Navigation
|
||||
$nav-child-link-color: #B8BAC8; // Neutral 300 — secondaryDark
|
||||
$search-result-preview-color: #B8BAC8;
|
||||
|
||||
// Code
|
||||
$code-background-color: #0F1017; // Neutral 950 — surfaceContainerLowestDark
|
||||
$code-linenumber-color: #9496A6; // Neutral 400
|
||||
|
||||
// Borders
|
||||
$border-color: #3D3E50; // Neutral 700 — outlineVariantDark
|
||||
$table-background-color: #242533; // Interpolated — surfaceContainerDark
|
||||
|
||||
// Buttons
|
||||
$btn-primary-color: #67EA94; // Green 500
|
||||
$base-button-color: #2C2D3C; // Neutral 800
|
||||
|
||||
// Feedback / callouts
|
||||
$feedback-color: #242533; // surfaceContainerDark
|
||||
|
||||
// Search
|
||||
$search-background-color: #242533; // surfaceContainerDark
|
||||
39
docs/_sass/color_schemes/meshtastic.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
// Meshtastic Material 3 — Light color scheme for just-the-docs
|
||||
// Source: core/ui/.../Color.kt §8.2 (Light Scheme)
|
||||
//
|
||||
// Brand colors:
|
||||
// Primary (Light): #2D8F52 (Green 700)
|
||||
// Surface (Light): #F5F6FA (Neutral 50)
|
||||
|
||||
$color-scheme: light;
|
||||
|
||||
// Body / page background
|
||||
$body-background-color: #F5F6FA; // Neutral 50 — surfaceLight
|
||||
$sidebar-color: #ECEDF3; // Neutral 100 — surfaceContainerLight
|
||||
$body-text-color: #2C2D3C; // Neutral 800 — onSurfaceLight
|
||||
$body-heading-color: #2C2D3C; // Neutral 800
|
||||
|
||||
// Links
|
||||
$link-color: #2D8F52; // Green 700 — primaryLight
|
||||
$nav-link-color: #2D8F52; // Green 700
|
||||
|
||||
// Navigation
|
||||
$nav-child-link-color: #555668; // Neutral 600 — secondaryLight
|
||||
$search-result-preview-color: #555668;
|
||||
|
||||
// Code
|
||||
$code-background-color: #ECEDF3; // Neutral 100
|
||||
$code-linenumber-color: #9496A6; // Neutral 400
|
||||
|
||||
// Borders
|
||||
$border-color: #D5D6E0; // Neutral 200 — outlineVariantLight
|
||||
$table-background-color: #F5F6FA; // Neutral 50
|
||||
|
||||
// Buttons
|
||||
$btn-primary-color: #2D8F52; // Green 700
|
||||
|
||||
// Feedback / callouts
|
||||
$feedback-color: #E0E1EB; // surfaceContainerHighLight
|
||||
|
||||
// Search
|
||||
$search-background-color: #FFFFFF; // surfaceContainerLowestLight
|
||||
3
docs/assets/css/just-the-docs-meshtastic-dark.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
---
|
||||
{% include css/just-the-docs.scss.liquid color_scheme="meshtastic-dark" %}
|
||||
3
docs/assets/css/just-the-docs-meshtastic.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
---
|
||||
{% include css/just-the-docs.scss.liquid color_scheme="meshtastic" %}
|
||||
41
docs/assets/screenshots/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Screenshots
|
||||
|
||||
This directory contains screenshot assets referenced by the documentation pages.
|
||||
|
||||
Screenshots are sourced from the Compose Preview Screenshot Testing reference images
|
||||
in `screenshot-tests/src/screenshotTestDebug/reference/`. Light-mode variants are
|
||||
copied here for use by the Jekyll docs site and in-app documentation browser.
|
||||
|
||||
## Updating Screenshots
|
||||
|
||||
After changing a UI component, regenerate reference images and copy them here:
|
||||
|
||||
```bash
|
||||
./gradlew :screenshot-tests:updateDebugScreenshotTest
|
||||
```
|
||||
|
||||
Then copy the relevant light-mode PNGs from the reference directory. The
|
||||
`copyDocsScreenshots` task automates bulk copying based on the manifest:
|
||||
|
||||
```bash
|
||||
./gradlew :screenshot-tests:copyDocsScreenshots
|
||||
```
|
||||
|
||||
## Naming Convention
|
||||
|
||||
```
|
||||
{page-id}_{description}.png
|
||||
```
|
||||
|
||||
Examples:
|
||||
- `onboarding_welcome.png`
|
||||
- `connections_bluetooth_scan.png`
|
||||
- `messages-and-channels_channel_list.png`
|
||||
- `firmware_disclaimer.png`
|
||||
|
||||
## Guidelines
|
||||
|
||||
- PNG format, light-mode only (dark variants live in reference directory)
|
||||
- Name screenshots to match the docs page they appear in
|
||||
- Keep filenames lowercase with underscores
|
||||
|
||||
BIN
docs/assets/screenshots/connections_bluetooth_scan.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/assets/screenshots/connections_connecting.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/assets/screenshots/connections_disconnect.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
docs/assets/screenshots/connections_empty_state.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/assets/screenshots/connections_transport_filters.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/assets/screenshots/connections_wifi_device_found.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/assets/screenshots/connections_wifi_scanning.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/assets/screenshots/connections_wifi_success.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
docs/assets/screenshots/docs-browser_chirpy.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/assets/screenshots/docs-browser_page.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/assets/screenshots/docs-browser_search.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
docs/assets/screenshots/docs-browser_toc.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
docs/assets/screenshots/firmware_checking.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
docs/assets/screenshots/firmware_disclaimer.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/assets/screenshots/firmware_error.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
docs/assets/screenshots/firmware_success.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/assets/screenshots/map_controls_overlay.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
docs/assets/screenshots/messages-and-channels_channel_list.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/assets/screenshots/messages_channel_info.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
docs/assets/screenshots/messages_quick_chat.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
docs/assets/screenshots/messages_reaction.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
docs/assets/screenshots/node-metrics_telemetric_actions.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/assets/screenshots/nodes_battery_info.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
docs/assets/screenshots/nodes_detail_local.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
docs/assets/screenshots/nodes_detail_section.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/assets/screenshots/nodes_distance_info.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
docs/assets/screenshots/nodes_environment_metrics.png
Normal file
|
After Width: | Height: | Size: 68 B |
BIN
docs/assets/screenshots/nodes_hops_info.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
docs/assets/screenshots/nodes_last_heard.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
docs/assets/screenshots/nodes_node_list.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/assets/screenshots/nodes_position.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/assets/screenshots/nodes_signal_info.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
docs/assets/screenshots/onboarding_welcome.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
docs/assets/screenshots/settings-radio-user_lora_config.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/assets/screenshots/settings_app_info.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
docs/assets/screenshots/settings_appearance.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
docs/assets/screenshots/settings_dropdown.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/assets/screenshots/settings_ipv4_field.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
docs/assets/screenshots/settings_password_field.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
docs/assets/screenshots/settings_persistence.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/assets/screenshots/settings_slider.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/assets/screenshots/settings_switch.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
docs/assets/screenshots/settings_text_field.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
docs/assets/screenshots/settings_titled_card.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
49
docs/developer.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
title: Developer Guide
|
||||
layout: default
|
||||
nav_order: 2
|
||||
has_children: true
|
||||
parent: ""
|
||||
---
|
||||
|
||||
# Developer Guide
|
||||
|
||||
Technical documentation for contributing to the Meshtastic Android and Desktop app.
|
||||
|
||||
---
|
||||
|
||||
## Before You Open a PR
|
||||
|
||||
Things that trip up first-time contributors — check these before requesting review:
|
||||
|
||||
- **Formatting passes** — run `./gradlew spotlessApply` to auto-format, then verify with `spotlessCheck`
|
||||
- **Detekt passes** — run `./gradlew detekt` and fix all reported issues
|
||||
- **All tests pass** — run `./gradlew test allTests` (both are needed: `test` covers Android-only modules, `allTests` covers KMP)
|
||||
- **Screenshot tests pass** — if you touched any Compose UI, run `./gradlew :screenshot-tests:validateFdroidDebugScreenshotTest` and update reference images if needed
|
||||
- **Proto submodule unchanged** — `core/proto/` is a read-only git submodule. Never modify proto files directly
|
||||
- **Docs updated** — if you changed user-visible UI, update the corresponding page under `docs/user/`. The `UI & Docs Governance` CI workflow will flag the PR if you didn't. Add the `skip-docs-check` label if it genuinely isn't needed
|
||||
- **Previews updated** — if you changed UI composables, update the corresponding `*Previews.kt` file and screenshot tests. The governance workflow will post an advisory. Add `skip-preview-check` to dismiss
|
||||
- **Branch naming** — branches must start with `feat/`, `fix/`, `chore/`, `docs/`, `build/`, `ci/`, `refactor/`, `test/`, or `deps/`
|
||||
|
||||
---
|
||||
|
||||
## What's New for Developers
|
||||
|
||||
<!-- DEV_WHATS_NEW_START -->
|
||||
<!-- Add new entries at the top. Format:
|
||||
**Month YYYY** — [Page or area](relative/path) — One sentence on what changed architecturally or procedurally.
|
||||
Keep the last 5–8 entries and trim older ones from the bottom.
|
||||
-->
|
||||
|
||||
**May 2026** — [Measurement & Formatting](developer/measurement) — New page documenting the `MetricFormatter` API, locale-aware unit conversion patterns, and how to add new measurement types.
|
||||
|
||||
**May 2026** — [Testing](developer/testing) — Compose Preview Screenshot Testing (CST) integrated: `screenshot-tests/` module, `@PreviewTest` wrappers, CI validation, docs asset pipeline.
|
||||
|
||||
**May 2026** — In-app documentation system added: markdown source under `docs/user/` and `docs/developer/` is bundled as Compose Resources and rendered via `multiplatform-markdown-renderer-m3`.
|
||||
|
||||
**May 2026** — [Architecture](developer/architecture) — Documented KMP module layering, Navigation 3 patterns, and feature module conventions.
|
||||
|
||||
**May 2026** — [Contributing](developer/contributing) — Established docs governance CI workflow for PRs that change UI without updating docs.
|
||||
|
||||
<!-- DEV_WHATS_NEW_END -->
|
||||
|
||||
147
docs/developer/adding-a-feature-module.md
Normal file
@@ -0,0 +1,147 @@
|
||||
---
|
||||
title: Adding a Feature Module
|
||||
nav_order: 3
|
||||
last_updated: 2026-05-13
|
||||
aliases:
|
||||
- new-module
|
||||
- feature-module
|
||||
- module-guide
|
||||
---
|
||||
|
||||
# Adding a Feature Module
|
||||
|
||||
Step-by-step guide for creating a new KMP feature module in the Meshtastic project.
|
||||
|
||||
## 1. Create the Module Directory
|
||||
|
||||
```bash
|
||||
mkdir -p feature/my-feature/src/{commonMain,commonTest,androidMain,jvmMain,iosMain}/kotlin/org/meshtastic/feature/myfeature
|
||||
```
|
||||
|
||||
## 2. Create `build.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.meshtastic.kmp.feature)
|
||||
alias(libs.plugins.meshtastic.kotlinx.serialization)
|
||||
id("meshtastic.kmp.jvm.android")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "org.meshtastic.feature.myfeature"
|
||||
androidResources.enable = false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.navigation)
|
||||
implementation(projects.core.resources)
|
||||
implementation(projects.core.ui)
|
||||
implementation(projects.core.di)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.compose.multiplatform.ui.test)
|
||||
}
|
||||
|
||||
jvmTest.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Register in `settings.gradle.kts`
|
||||
|
||||
Add your module to the main `include()` block:
|
||||
|
||||
```kotlin
|
||||
include(
|
||||
// ...existing modules...
|
||||
":feature:my-feature",
|
||||
)
|
||||
```
|
||||
|
||||
## 4. Create the DI Module
|
||||
|
||||
`src/commonMain/kotlin/org/meshtastic/feature/myfeature/di/FeatureMyFeatureModule.kt`:
|
||||
|
||||
```kotlin
|
||||
package org.meshtastic.feature.myfeature.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Module
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.feature.myfeature")
|
||||
class FeatureMyFeatureModule
|
||||
```
|
||||
|
||||
## 5. Register DI in App/Desktop
|
||||
|
||||
Add your module to:
|
||||
- `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt`
|
||||
- `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`
|
||||
|
||||
## 6. Add Navigation Routes
|
||||
|
||||
In `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`:
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
sealed interface MyFeatureRoute : Route {
|
||||
@Serializable data object MyFeatureGraph : MyFeatureRoute, Graph
|
||||
@Serializable data object MyFeatureHome : MyFeatureRoute
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Create Navigation Entries
|
||||
|
||||
`src/commonMain/kotlin/org/meshtastic/feature/myfeature/navigation/MyFeatureNavigation.kt`:
|
||||
|
||||
```kotlin
|
||||
package org.meshtastic.feature.myfeature.navigation
|
||||
|
||||
import androidx.navigation3.runtime.EntryProviderScope
|
||||
import org.meshtastic.core.navigation.MyFeatureRoute
|
||||
|
||||
fun EntryProviderScope<*>.myFeatureEntries() {
|
||||
entry<MyFeatureRoute.MyFeatureHome> {
|
||||
MyFeatureScreen()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Source Set Guidelines
|
||||
|
||||
| Source Set | Contains |
|
||||
|-----------|----------|
|
||||
| `commonMain` | Models, ViewModels, shared UI, DI module, navigation |
|
||||
| `androidMain` | Android-specific implementations (e.g., platform APIs) |
|
||||
| `jvmMain` | Desktop-specific implementations |
|
||||
| `iosMain` | iOS-specific implementations |
|
||||
| `commonTest` | Shared unit tests |
|
||||
|
||||
## 9. Testing Expectations
|
||||
|
||||
Every feature module should have:
|
||||
- Unit tests in `commonTest` for business logic
|
||||
- UI tests using `compose-multiplatform-ui-test` where appropriate
|
||||
- No test dependency on other feature modules
|
||||
|
||||
## 10. Checklist
|
||||
|
||||
- [ ] Module directory created
|
||||
- [ ] `build.gradle.kts` with correct plugins and dependencies
|
||||
- [ ] Added to `settings.gradle.kts`
|
||||
- [ ] DI module created with `@ComponentScan`
|
||||
- [ ] DI module registered in app and desktop roots
|
||||
- [ ] Routes added to `Routes.kt`
|
||||
- [ ] Navigation entries registered
|
||||
- [ ] `./gradlew kmpSmokeCompile` passes
|
||||
- [ ] `./gradlew :feature:my-feature:allTests` passes
|
||||
|
||||
---
|
||||
|
||||
132
docs/developer/architecture.md
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
title: Architecture
|
||||
nav_order: 1
|
||||
last_updated: 2026-05-13
|
||||
aliases:
|
||||
- layers
|
||||
- module-architecture
|
||||
- kmp
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
The Meshtastic Android/Desktop/iOS application follows a modular Kotlin Multiplatform (KMP) architecture with clear layer boundaries.
|
||||
|
||||
## Layer Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ app / desktop │ Platform entry points
|
||||
├─────────────────────────────────────────────┤
|
||||
│ feature/* modules │ UI + Business Logic
|
||||
├─────────────────────────────────────────────┤
|
||||
│ core/* modules │ Shared infrastructure
|
||||
├─────────────────────────────────────────────┤
|
||||
│ Platform (Android/JVM/iOS) │ OS-specific bindings
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Module Categories
|
||||
|
||||
### `app/` — Android Application
|
||||
|
||||
The Android application entry point:
|
||||
- Activity, Application, and Manifest definitions
|
||||
- Koin DI module composition (`AppKoinModule`)
|
||||
- Flavor-specific bindings (`google/`, `fdroid/`)
|
||||
- Android-only integrations (widgets, services)
|
||||
|
||||
### `desktop/` — Desktop JVM Application
|
||||
|
||||
The Desktop (Linux/macOS/Windows) entry point:
|
||||
- Compose Desktop window management
|
||||
- Desktop-specific DI (`DesktopKoinModule`)
|
||||
- Platform stubs for Android-only capabilities
|
||||
- Serial transport implementation
|
||||
|
||||
### `feature/*` — Feature Modules
|
||||
|
||||
Each `feature/` module owns a vertical slice of functionality:
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| `feature:intro` | Onboarding/welcome flow |
|
||||
| `feature:messaging` | Messages, channels, contacts, quick chat |
|
||||
| `feature:connections` | Bluetooth/USB/TCP connection management |
|
||||
| `feature:map` | Map display, waypoints |
|
||||
| `feature:node` | Node list, node detail, metrics |
|
||||
| `feature:settings` | All configuration screens |
|
||||
| `feature:firmware` | Firmware update flow |
|
||||
| `feature:docs` | In-app documentation browser |
|
||||
| `feature:wifi-provision` | WiFi provisioning |
|
||||
| `feature:widget` | Android home screen widgets |
|
||||
|
||||
Feature modules:
|
||||
- Use the `meshtastic.kmp.feature` convention plugin
|
||||
- Depend on `core` modules, never on other `feature` modules
|
||||
- Own their navigation entries and DI registrations
|
||||
- Contain platform-specific implementations in `androidMain`/`jvmMain`/`iosMain`
|
||||
|
||||
### `core/*` — Core Modules
|
||||
|
||||
Shared infrastructure used by all features:
|
||||
|
||||
| Module | Responsibility |
|
||||
|--------|---------------|
|
||||
| `core:common` | Utilities, extensions, build config |
|
||||
| `core:navigation` | Routes, deep links, Navigation 3 |
|
||||
| `core:ui` | Shared Compose components, icons, theme |
|
||||
| `core:resources` | Shared string resources |
|
||||
| `core:model` | Domain models |
|
||||
| `core:data` | Data layer abstractions |
|
||||
| `core:database` | Room KMP database |
|
||||
| `core:datastore` | DataStore preferences |
|
||||
| `core:prefs` | App preferences |
|
||||
| `core:repository` | Repository interfaces |
|
||||
| `core:service` | Mesh service layer |
|
||||
| `core:di` | DI utilities |
|
||||
| `core:network` | HTTP/serial/transport |
|
||||
| `core:ble` | Bluetooth LE abstractions |
|
||||
| `core:proto` | Protobuf definitions |
|
||||
| `core:testing` | Test utilities |
|
||||
|
||||
## KMP Source Sets
|
||||
|
||||
Each module uses the standard KMP source set hierarchy:
|
||||
|
||||
```
|
||||
src/
|
||||
├── commonMain/ ← Shared code (all platforms)
|
||||
├── commonTest/ ← Shared tests
|
||||
├── androidMain/ ← Android-specific
|
||||
├── jvmMain/ ← Desktop JVM-specific
|
||||
├── iosMain/ ← iOS-specific
|
||||
└── jvmTest/ ← Desktop test host
|
||||
```
|
||||
|
||||
**Golden Rules:**
|
||||
- No `android.*` imports in `commonMain`
|
||||
- Platform-specific code goes in appropriate source set
|
||||
- Prefer interfaces + DI over `expect`/`actual` for complex behaviors
|
||||
- Use `expect`/`actual` only for simple declarations
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
The project uses **Koin** with annotation processing:
|
||||
- `@Module`, `@Single`, `@Factory` annotations
|
||||
- `@ComponentScan` for automatic registration
|
||||
- Feature modules export their own `Feature*Module` class
|
||||
- App/Desktop compose all modules in their root DI configuration
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigation uses **Navigation 3** with typed routes:
|
||||
- All routes defined in `core/navigation/Routes.kt`
|
||||
- Routes are `@Serializable` data classes/objects
|
||||
- Deep links resolved through `DeepLinkRouter`
|
||||
- Each feature registers its own navigation entries
|
||||
|
||||
See [Navigation & Deep Links](navigation-and-deep-links) for details.
|
||||
|
||||
---
|
||||
|
||||
137
docs/developer/codebase.md
Normal file
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||