docs(specs): add feature specs for discovery, node-list-layout, and app-docs (#5388)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-05-07 16:16:58 -05:00
committed by GitHub
parent 934e687bc5
commit c0d95d6ac4
29 changed files with 5100 additions and 0 deletions

View File

@@ -163,3 +163,4 @@ Consult `.skills/` for detailed playbooks:
- `.skills/testing-ci/` — CI architecture, verification matrix
- `.skills/implement-feature/` — Feature development workflow
- `.skills/code-review/` — PR hygiene checklist
- `.skills/speckit/` — Spec Kit SDD workflow, slash commands, constitution

281
.github/extensions/speckit/extension.mjs vendored Normal file
View File

@@ -0,0 +1,281 @@
// Extension: speckit
// Spec Kit SDD workflow tools for Meshtastic Android
import { joinSession } from "@github/copilot-sdk/extension";
import { readdir, readFile, stat } from "node:fs/promises";
import { join, basename } from "node:path";
const SPECS_DIR = join(process.cwd(), "specs");
const SPECIFY_DIR = join(process.cwd(), ".specify");
async function dirExists(p) {
try {
return (await stat(p)).isDirectory();
} catch {
return false;
}
}
async function fileExists(p) {
try {
return (await stat(p)).isFile();
} catch {
return false;
}
}
async function discoverSpecs() {
if (!(await dirExists(SPECS_DIR))) return [];
const entries = await readdir(SPECS_DIR, { withFileTypes: true });
const specs = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const specDir = join(SPECS_DIR, entry.name);
const specFile = join(specDir, "spec.md");
if (!(await fileExists(specFile))) continue;
const files = await readdir(specDir, { withFileTypes: true });
const artifacts = [];
for (const f of files) {
if (f.isFile()) artifacts.push(f.name);
if (f.isDirectory()) {
const subFiles = await readdir(join(specDir, f.name));
for (const sf of subFiles) artifacts.push(`${f.name}/${sf}`);
}
}
const hasSpec = artifacts.includes("spec.md");
const hasPlan = artifacts.includes("plan.md");
const hasTasks = artifacts.includes("tasks.md");
let title = entry.name;
try {
const content = await readFile(specFile, "utf-8");
const match = content.match(/^#\s+(.+)/m);
if (match) title = match[1];
} catch { /* use dir name */ }
let taskStats = null;
if (hasTasks) {
try {
const tasksContent = await readFile(join(specDir, "tasks.md"), "utf-8");
const taskLines = tasksContent.match(/^[-*]\s+\[[ x]\]/gm) || [];
const done = tasksContent.match(/^[-*]\s+\[x\]/gmi) || [];
taskStats = { total: taskLines.length, done: done.length };
} catch { /* skip */ }
}
specs.push({
id: entry.name,
title,
artifacts,
hasSpec,
hasPlan,
hasTasks,
taskStats,
path: specDir,
});
}
return specs.sort((a, b) => a.id.localeCompare(b.id));
}
const session = await joinSession({
tools: [
{
name: "speckit_list",
description:
"List all feature specs in the specs/ directory with their artifacts and task progress. " +
"Use this to discover which specs exist and their current state.",
parameters: { type: "object", properties: {} },
skipPermission: true,
handler: async () => {
const specs = await discoverSpecs();
if (specs.length === 0) {
return "No specs found in specs/ directory. Use /speckit.specify to create one.";
}
const lines = ["# Feature Specs\n"];
for (const s of specs) {
const status = [];
if (s.hasSpec) status.push("spec ✓");
if (s.hasPlan) status.push("plan ✓");
if (s.hasTasks) status.push("tasks ✓");
let progress = "";
if (s.taskStats) {
const pct = s.taskStats.total > 0
? Math.round((s.taskStats.done / s.taskStats.total) * 100)
: 0;
progress = ` | ${s.taskStats.done}/${s.taskStats.total} tasks (${pct}%)`;
}
lines.push(`## ${s.id}`);
lines.push(`**${s.title}**`);
lines.push(`Artifacts: ${status.join(", ")}${progress}`);
lines.push(`Files: ${s.artifacts.join(", ")}`);
lines.push(`Path: ${s.path}\n`);
}
return lines.join("\n");
},
},
{
name: "speckit_load",
description:
"Load the primary artifacts (spec.md, plan.md, tasks.md) for a specific feature spec. " +
"Provide the spec ID (directory name, e.g. '001-local-mesh-discovery') or a partial match. " +
"Optionally load only specific artifacts.",
parameters: {
type: "object",
properties: {
spec_id: {
type: "string",
description:
"The spec directory name or partial match (e.g. '001', 'mesh-discovery', 'node-list')",
},
artifacts: {
type: "array",
items: { type: "string" },
description:
"Which artifacts to load. Defaults to ['spec.md', 'plan.md', 'tasks.md']. " +
"Can include any file path like 'data-model.md', 'contracts/deep-links.md', etc.",
},
},
required: ["spec_id"],
},
skipPermission: true,
handler: async (args) => {
const specs = await discoverSpecs();
const query = args.spec_id.toLowerCase();
const match = specs.find(
(s) =>
s.id.toLowerCase() === query ||
s.id.toLowerCase().includes(query),
);
if (!match) {
const available = specs.map((s) => s.id).join(", ");
return `No spec matching '${args.spec_id}'. Available: ${available || "none"}`;
}
const toLoad = args.artifacts || ["spec.md", "plan.md", "tasks.md"];
const results = [];
for (const artifact of toLoad) {
const filePath = join(match.path, artifact);
if (await fileExists(filePath)) {
const content = await readFile(filePath, "utf-8");
results.push(`--- ${artifact} (${match.id}) ---\n${content}`);
} else {
results.push(`--- ${artifact} --- NOT FOUND`);
}
}
return results.join("\n\n");
},
},
{
name: "speckit_constitution",
description:
"Display the project constitution that all specs must conform to. " +
"The constitution defines non-negotiable principles for the Meshtastic Android project.",
parameters: { type: "object", properties: {} },
skipPermission: true,
handler: async () => {
const constitutionPath = join(SPECIFY_DIR, "memory", "constitution.md");
if (!(await fileExists(constitutionPath))) {
return "No constitution found at .specify/memory/constitution.md. Use /speckit.constitution to create one.";
}
return await readFile(constitutionPath, "utf-8");
},
},
{
name: "speckit_status",
description:
"Show overall Spec Kit workflow status: specs count, constitution version, " +
"template availability, and readiness for each workflow stage.",
parameters: { type: "object", properties: {} },
skipPermission: true,
handler: async () => {
const specs = await discoverSpecs();
const hasConstitution = await fileExists(join(SPECIFY_DIR, "memory", "constitution.md"));
const hasTemplates = await dirExists(join(SPECIFY_DIR, "templates"));
const hasExtensions = await fileExists(join(SPECIFY_DIR, "extensions.yml"));
let constitutionVersion = "none";
if (hasConstitution) {
try {
const content = await readFile(
join(SPECIFY_DIR, "memory", "constitution.md"),
"utf-8",
);
const match = content.match(/\*\*Version\*\*:\s*([\d.]+)/i) ||
content.match(/version[:\s]+v?([\d.]+)/i);
if (match) constitutionVersion = `v${match[1]}`;
} catch { /* skip */ }
}
let templateList = [];
if (hasTemplates) {
try {
const entries = await readdir(join(SPECIFY_DIR, "templates"));
templateList = entries.filter((e) => e.endsWith(".md"));
} catch { /* skip */ }
}
const lines = [
"# Spec Kit Status\n",
`**Constitution:** ${hasConstitution ? `✓ (${constitutionVersion})` : "✗ not found"}`,
`**Templates:** ${templateList.length > 0 ? `✓ (${templateList.join(", ")})` : "✗ none"}`,
`**Extensions:** ${hasExtensions ? "✓ configured" : "✗ not found"}`,
`**Specs:** ${specs.length} feature(s)\n`,
];
if (specs.length > 0) {
lines.push("| Spec | Spec.md | Plan.md | Tasks.md | Progress |");
lines.push("|------|---------|---------|----------|----------|");
for (const s of specs) {
const progress = s.taskStats
? `${s.taskStats.done}/${s.taskStats.total}`
: "—";
lines.push(
`| ${s.id} | ${s.hasSpec ? "✓" : "✗"} | ${s.hasPlan ? "✓" : "✗"} | ${s.hasTasks ? "✓" : "✗"} | ${progress} |`,
);
}
}
lines.push(
"\n## Workflow Commands",
"specify → clarify → plan → tasks → analyze → implement",
"\nUse `/speckit.specify` to start a new feature, or `speckit_load` to review an existing one.",
);
return lines.join("\n");
},
},
],
hooks: {
onSessionStart: async () => {
const specs = await discoverSpecs();
if (specs.length === 0) return;
const summary = specs
.map((s) => {
const progress = s.taskStats
? ` (${s.taskStats.done}/${s.taskStats.total} tasks)`
: "";
return `- ${s.id}: ${s.title}${progress}`;
})
.join("\n");
return {
additionalContext: [
`[Spec Kit] ${specs.length} feature spec(s) found in specs/:`,
summary,
"",
"Use speckit_list, speckit_load, speckit_status, or speckit_constitution tools for spec details.",
"Use /speckit.specify, /speckit.plan, /speckit.tasks, /speckit.analyze, /speckit.implement for workflow commands.",
].join("\n"),
};
},
},
});

182
.skills/speckit/SKILL.md Normal file
View File

@@ -0,0 +1,182 @@
# Skill: Spec Kit (Specification-Driven Development)
## Description
Spec Kit is the project's specification-driven development (SDD) workflow. It takes a natural-language
feature description through a structured pipeline that produces specs, plans, tasks, and implementation
— with review gates, constitution alignment, and automated git hooks at each stage.
## When to Use
- Starting a **new feature** that needs specification before implementation.
- **Refining** an existing feature spec after user feedback or clarification.
- **Analyzing** cross-artifact consistency before implementation begins.
- **Generating tasks** from an approved plan.
- **Implementing** a fully-specified feature with task tracking.
- Converting tasks to **GitHub Issues** for project management.
## Available Commands
### Core Workflow (in order)
| Command | Slash Command | Purpose |
|---------|--------------|---------|
| **Specify** | `/speckit.specify` | Create or update `spec.md` from a feature description |
| **Clarify** | `/speckit.clarify` | Ask up to 5 targeted clarification questions, encode answers into spec |
| **Plan** | `/speckit.plan` | Generate `plan.md` with architecture, phases, data models |
| **Tasks** | `/speckit.tasks` | Generate `tasks.md` with dependency-ordered, parallelizable tasks |
| **Analyze** | `/speckit.analyze` | Read-only cross-artifact consistency and quality analysis |
| **Implement** | `/speckit.implement` | Execute tasks from `tasks.md` with status tracking |
### Supporting Commands
| Command | Slash Command | Purpose |
|---------|--------------|---------|
| **Checklist** | `/speckit.checklist` | Generate a custom quality checklist for the feature |
| **Constitution** | `/speckit.constitution` | Create or update project constitution (`.specify/memory/constitution.md`) |
| **Tasks to Issues** | `/speckit.taskstoissues` | Convert `tasks.md` into GitHub Issues |
### Git Extension Commands
| Command | Slash Command | Purpose |
|---------|--------------|---------|
| **Git Initialize** | `/speckit.git.initialize` | Initialize git repo (skips if already initialized) |
| **Git Feature** | `/speckit.git.feature` | Create a feature branch with sequential numbering |
| **Git Commit** | `/speckit.git.commit` | Auto-commit changes after a Spec Kit command |
| **Git Remote** | `/speckit.git.remote` | Detect git remote URL for GitHub integration |
| **Git Validate** | `/speckit.git.validate` | Validate branch follows feature naming conventions |
## Full Workflow (End-to-End)
The standard SDD cycle for a new feature:
```text
1. /speckit.specify "Feature description here"
→ Creates specs/<NNN>-feature-name/spec.md
→ Auto-creates feature branch via git hook
2. /speckit.clarify
→ Asks clarification questions, encodes answers into spec.md
3. /speckit.plan
→ Generates plan.md with architecture, phases, data model
4. /speckit.tasks
→ Generates tasks.md with phased, dependency-ordered tasks
5. /speckit.analyze
→ Read-only quality analysis (constitution alignment, coverage gaps)
→ Fix any CRITICAL/HIGH findings before proceeding
6. /speckit.implement
→ Executes tasks with status tracking
→ Auto-commits after each phase
```
## Automated Workflow
The full cycle can also run as a single workflow with review gates:
```text
/speckit.workflow speckit "Describe the feature"
```
This runs: specify → (review gate) → plan → (review gate) → tasks → implement
## File Structure
Spec Kit produces files under `specs/<NNN>-feature-name/`:
```
specs/
└── 001-feature-name/
├── spec.md # Feature specification (FRs, NFRs, SCs, user stories)
├── plan.md # Implementation plan (architecture, phases)
├── tasks.md # Dependency-ordered task list
├── data-model.md # Entity definitions and schemas
├── research.md # Technical decisions and alternatives
├── quickstart.md # Getting started guide
├── checklists/
│ └── requirements.md # Quality checklist
└── contracts/
├── deep-links.md # Deep link contract
└── *.json # Schema contracts
```
## Constitution
The project constitution at `.specify/memory/constitution.md` defines non-negotiable principles.
All specs, plans, and tasks are validated against it during `/speckit.analyze`.
Current constitution (v1.1.0) enforces 6 principles:
1. **KMP Core** — Business logic in `commonMain` only
2. **Zero Lint Tolerance**`spotlessCheck` + `detekt` must pass
3. **Compose Multiplatform UI** — CMP, not Android-only Compose
4. **Privacy First** — No PII/location/key exposure
5. **Design Standards Compliance** — Review against Meshtastic design standards
6. **Verify Before Push** — Local verification before any `git push`
## Extension Hooks
Git hooks are configured in `.specify/extensions.yml` and run automatically:
- **Before** each command: Optional commit hook (commit outstanding changes)
- **After** each command: Optional commit hook (commit generated artifacts)
- **Before specify**: Mandatory feature branch creation
## Meshtastic-Specific Conventions
### Branch Naming
Feature branches created by `/speckit.git.feature` follow the project convention:
`<NNN>-feature-name` (e.g., `001-local-mesh-discovery`)
### Task ID Namespacing
To avoid collision when multiple specs exist, prefix task IDs by feature:
| Spec | Prefix | Example |
|------|--------|---------|
| 001-local-mesh-discovery | `D` | D001, D002, ... |
| 002-node-list-layout | `NL-T` | NL-T001, NL-T002, ... |
| 003-app-docs-markdown | `T` | T000, T010, ... |
### Design Standards Gate
All specs with UI work must include a Phase 0 `[UI-GATE]` blocking task that reviews
the Meshtastic design standards before implementation begins. The `/speckit.analyze`
command flags missing gates as CRITICAL.
### Deep Link Convention
All deep links must use the canonical URI scheme: `meshtastic://meshtastic/settings/<path>`.
Compatibility aliases with camelCase or hyphenated variants are acceptable but must not be
the primary contract.
## Tips
- Run `/speckit.analyze` before `/speckit.implement` — it catches constitution violations,
coverage gaps, and cross-artifact inconsistencies.
- Use `/speckit.clarify` when specs feel underspecified — it asks targeted questions and
encodes answers directly into the spec.
- The `/speckit.checklist` command generates feature-specific quality gates beyond the
standard code review checklist.
- All commands support passing arguments: `/speckit.specify "description"`.
- Git hooks are optional by default (you'll be prompted). Set `auto_execute_hooks: true`
in `.specify/extensions.yml` to skip prompts.
## Existing Specs
| ID | Feature | Status | FRs | Tasks |
|----|---------|--------|-----|-------|
| 001 | Local Mesh Discovery | Not Started | 38 | 49 |
| 002 | Node List Layout | Not Started | 27 | 38 |
| 003 | App Documentation | Not Started | 37 | 139 |
## Related Skills
- `implement-feature` — Feature implementation workflow (post-spec)
- `code-review` — PR review checklist
- `testing-ci` — CI validation commands
- `new-branch` — Branch bootstrap recipes

View File

@@ -18,6 +18,7 @@ You are an expert Android/KMP engineer. Maintain architectural boundaries, use M
- `.skills/implement-feature/` - Feature workflow.
- `.skills/code-review/` - **PR & Commit Hygiene**, validation checklist.
- `.skills/new-branch/` - Branching and rebasing recipes.
- `.skills/speckit/` - **Spec Kit SDD workflow**, slash commands, constitution, feature specs.
</context_and_memory>
<process_essentials>

View File

@@ -0,0 +1,84 @@
# Requirements Quality Checklist — Local Mesh Discovery
Use this checklist to review the discovery specification before implementation starts.
## Scope and User Value
- [ ] The spec clearly states the user problem Local Mesh Discovery solves.
- [ ] The feature scope is limited to diagnostic discovery, summary, history, and export.
- [ ] Out-of-scope items are explicitly listed.
- [ ] The feature remains useful even when AI is unavailable.
## User Stories
- [ ] Five user stories are present and remain prioritized P1-P5.
- [ ] Each user story is independently testable.
- [ ] Each user story explains why the priority matters.
- [ ] Each user story includes at least one independent test.
- [ ] Acceptance scenarios cover success, cancellation, and failure cases where relevant.
## Meshtastic-Android Architecture Fit
- [ ] The spec uses Compose Multiplatform + Material 3 terminology.
- [ ] The spec uses Navigation 3 typed routes / `NavKey` patterns.
- [ ] The spec keeps business logic in `commonMain`.
- [ ] Platform-specific work is limited to Android/Desktop map, export, and AI integrations.
- [ ] The feature module location is `feature/discovery/` and follows the `meshtastic.kmp.feature` convention.
- [ ] Koin module and ViewModel wiring expectations are documented.
## Persistence and Data Modeling
- [ ] Persistence uses Room KMP and aligns with existing `core:database` conventions.
- [ ] Session, preset-result, and discovered-node entities are all defined.
- [ ] Entity relationships, cascade behavior, and indexing are documented.
- [ ] DAO responsibilities are documented.
- [ ] Migration strategy is called out for `MeshtasticDatabase`.
- [ ] The design avoids storing raw packet dumps when aggregate/session reconstruction data is sufficient.
## Integration Points
- [ ] Neighbor info integration uses the existing `NeighborInfoHandler` path.
- [ ] Preset mutation reuses the existing admin/config flow.
- [ ] BLE reconnect handling reuses `BleReconnectPolicy` / `BleRadioTransport` behavior.
- [ ] Map rendering reuses the existing CompositionLocal provider pattern.
- [ ] Preferences use DataStore via `core:prefs`.
- [ ] 2.4 GHz gating uses existing hardware metadata infrastructure.
## UX and Product Behavior
- [ ] The scan state machine is documented.
- [ ] Progress, reconnect wait, analysis, and cancellation states are all defined.
- [ ] Partial sessions remain visible in history and summary.
- [ ] Summary ranking logic is documented even without AI.
- [ ] Map fallback behavior is defined for unsupported targets.
- [ ] Export/share behavior is defined for Android and Desktop.
## Safety and Failure Handling
- [ ] The feature restores the users home preset after completion, stop, or failure.
- [ ] The spec defines what happens when reconnect never succeeds.
- [ ] The spec defines what happens when hardware capability is unknown.
- [ ] The spec defines what happens when AI is unavailable or errors.
- [ ] The spec defines what happens when map rendering or export snapshots fail.
- [ ] The spec never assumes unsupported 2.4 GHz hardware can run a scan.
## Resource and Style Conventions
- [ ] User-visible strings are planned for `core/resources/src/commonMain/composeResources/values/strings.xml`.
- [ ] Icon guidance references `MeshtasticIcons`.
- [ ] Formatting guidance references shared Meshtastic formatters / models.
- [ ] All framework references are specific to the Meshtastic-Android KMP stack.
## Testing and Delivery
- [ ] The plan includes both targeted module tests and repository-wide verification commands.
- [ ] The tasks are phased and dependency-ordered.
- [ ] Parallelization opportunities are called out.
- [ ] The quickstart guide includes `test` and `allTests` guidance.
- [ ] The deep-link contract is documented separately.
## Review Outcome
- [ ] Ready for implementation
- [ ] Needs clarification before implementation
- [ ] Needs scope reduction before implementation

View File

@@ -0,0 +1,96 @@
# Deep Link Contract — Local Mesh Discovery
## Base URI
Meshtastic-Android deep links use the shared base URI from `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`:
```text
meshtastic://meshtastic
```
## Canonical Discovery Links
| Use case | URI | Resulting typed backstack |
|---|---|---|
| Open Local Mesh Discovery landing screen | `meshtastic://meshtastic/settings/local-mesh-discovery` | `SettingsRoute.SettingsGraph(destNum = null)`, `SettingsRoute.LocalMeshDiscovery` |
| Open Local Mesh Discovery for a settings context with `destNum` | `meshtastic://meshtastic/settings/{destNum}/local-mesh-discovery` | `SettingsRoute.SettingsGraph(destNum)`, `SettingsRoute.LocalMeshDiscovery` |
| Open a stored discovery session | `meshtastic://meshtastic/settings/local-mesh-discovery/session/{sessionId}` | `SettingsRoute.SettingsGraph(destNum = null)`, `SettingsRoute.LocalMeshDiscovery`, `SettingsRoute.LocalMeshDiscoverySession(sessionId)` |
## Compatibility Alias
For compatibility with camelCase path naming used in early design notes, the router may also accept the following alias paths and normalize them to the same typed routes:
- `meshtastic://meshtastic/settings/localMeshDiscovery`
- `meshtastic://meshtastic/settings/localMeshDiscovery/session/{sessionId}`
The canonical path used in docs, tests, and generated links should remain **hyphenated** to match existing Meshtastic-Android deep-link conventions.
## Proposed Route Additions
Add these typed routes to `SettingsRoute`:
```kotlin
@Serializable data object LocalMeshDiscovery : SettingsRoute
@Serializable data class LocalMeshDiscoverySession(val sessionId: String) : SettingsRoute
```
## Router Mapping Rules
### `DeepLinkRouter`
Add discovery entries to the settings-subroute mapping:
```kotlin
"local-mesh-discovery" to SettingsRoute.LocalMeshDiscovery
"localMeshDiscovery" to SettingsRoute.LocalMeshDiscovery
```
Session detail requires one additional parsing rule because it is nested under the feature slug:
- `/settings/local-mesh-discovery/session/{sessionId}`
- `/settings/localMeshDiscovery/session/{sessionId}`
### Navigation graph registration
The settings graph must register both routes so the typed backstack resolves correctly.
## Argument Contract
### `sessionId`
| Field | Type | Required | Rules |
|---|---|---:|---|
| `sessionId` | `String` | Yes for session detail | Must match a persisted `DiscoverySessionEntity.sessionId`. Use a UUID-like string; preserve case exactly. |
## Behavior Rules
1. If the landing deep link is opened while the user is not connected to a radio, the feature still opens and shows history / informational UI; only scan start is blocked.
2. If a session deep link references an unknown `sessionId`, the app should land on the discovery history/landing screen and show a non-fatal error or snackbar.
3. If a deep link includes a `destNum`, discovery should still verify that the feature is valid for the current local-radio context before allowing scan start.
4. Query parameters outside this contract should be ignored rather than causing route failure.
## Tests to Add
- `DeepLinkRouterTest`
- `/settings/local-mesh-discovery`
- `/settings/localMeshDiscovery`
- `/settings/local-mesh-discovery/session/{sessionId}`
- `/settings/{destNum}/local-mesh-discovery`
- `NavigationConfigTest`
- serialization coverage for `SettingsRoute.LocalMeshDiscovery`
- serialization coverage for `SettingsRoute.LocalMeshDiscoverySession(sessionId)`
## Failure Cases
| Input | Expected Result |
|---|---|
| `/settings/local-mesh-discovery/session/` | Ignore invalid detail path and fall back to `SettingsRoute.SettingsGraph + SettingsRoute.LocalMeshDiscovery` |
| `/settings/local-mesh-discovery/session/not-found` | Open discovery landing/history and show “session not found” UI state |
| `/settings/abc/local-mesh-discovery` | Treat `abc` as a subroute slug, not a `destNum`; fall back to discovery landing if matched, otherwise default settings behavior |
| Unknown query parameters | Ignore them |
## Implementation Notes
- `MainActivity` already forwards `ACTION_VIEW` intents through the shared deep-link router, so no discovery-specific activity plumbing should be required once router support exists.
- Keep path parsing centralized in `DeepLinkRouter` so Desktop and Android share the same typed route behavior.
- Prefer stable, human-readable route names because they become part of persistent backstack serialization and tests.

View File

@@ -0,0 +1,528 @@
# Data Model — Local Mesh Discovery
This document defines the Room KMP persistence model for Local Mesh Discovery. The model is intentionally normalized around **session**, **per-preset result**, and **per-node discovery observation** so that history, summary, map, and export views can be rebuilt from persisted state without a live radio connection.
## Design Goals
- Match Meshtastic-Android Room KMP conventions (`androidx.room3`, denormalized searchable columns, `Flow`-friendly DAO APIs).
- Keep the scan state machine in `commonMain` while letting Room store only durable facts.
- Preserve preset-specific observations instead of flattening all node sightings into a single session-level node table.
- Support cascade deletion and efficient session-detail loading.
## Proposed Package Layout
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoverySessionDao.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryPresetResultDao.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveredNodeDao.kt`
## Entity Definitions
> Notes
>
> - Examples use `androidx.room3` imports, matching the rest of the project.
> - Status values are stored as strings for schema readability and future-proofing.
> - `sessionId` is a string UUID generated in `commonMain`.
### `DiscoverySessionEntity`
```kotlin
@Entity(
tableName = "discovery_session",
indices = [
Index(value = ["started_at"]),
Index(value = ["status"]),
Index(value = ["my_node_num"]),
],
)
data class DiscoverySessionEntity(
@PrimaryKey
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "started_at")
val startedAt: Long,
@ColumnInfo(name = "ended_at")
val endedAt: Long? = null,
@ColumnInfo(name = "status")
val status: String,
@ColumnInfo(name = "display_name")
val displayName: String,
@ColumnInfo(name = "my_node_num")
val myNodeNum: Int?,
@ColumnInfo(name = "home_preset_key")
val homePresetKey: String,
@ColumnInfo(name = "selected_preset_count")
val selectedPresetCount: Int,
@ColumnInfo(name = "dwell_seconds")
val dwellSeconds: Int,
@ColumnInfo(name = "completed_preset_count")
val completedPresetCount: Int = 0,
@ColumnInfo(name = "failed_preset_count")
val failedPresetCount: Int = 0,
@ColumnInfo(name = "is_partial", defaultValue = "0")
val isPartial: Boolean = false,
@ColumnInfo(name = "best_preset_key")
val bestPresetKey: String? = null,
@ColumnInfo(name = "recommendation_source")
val recommendationSource: String? = null,
@ColumnInfo(name = "recommendation_text")
val recommendationText: String? = null,
@ColumnInfo(name = "error_message")
val errorMessage: String? = null,
)
```
### `DiscoveryPresetResultEntity`
```kotlin
@Entity(
tableName = "discovery_preset_result",
foreignKeys = [
ForeignKey(
entity = DiscoverySessionEntity::class,
parentColumns = ["session_id"],
childColumns = ["session_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["session_id"]),
Index(value = ["session_id", "preset_index"], unique = true),
Index(value = ["session_id", "preset_key"], unique = true),
Index(value = ["status"]),
],
)
data class DiscoveryPresetResultEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "preset_result_id")
val presetResultId: Long = 0,
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "preset_index")
val presetIndex: Int,
@ColumnInfo(name = "preset_key")
val presetKey: String,
@ColumnInfo(name = "status")
val status: String,
@ColumnInfo(name = "started_at")
val startedAt: Long? = null,
@ColumnInfo(name = "ended_at")
val endedAt: Long? = null,
@ColumnInfo(name = "planned_dwell_seconds")
val plannedDwellSeconds: Int,
@ColumnInfo(name = "actual_dwell_seconds")
val actualDwellSeconds: Int = 0,
@ColumnInfo(name = "reconnect_count")
val reconnectCount: Int = 0,
@ColumnInfo(name = "packet_count")
val packetCount: Int = 0,
@ColumnInfo(name = "telemetry_count")
val telemetryCount: Int = 0,
@ColumnInfo(name = "neighbor_info_count")
val neighborInfoCount: Int = 0,
@ColumnInfo(name = "unique_node_count")
val uniqueNodeCount: Int = 0,
@ColumnInfo(name = "median_snr")
val medianSnr: Float? = null,
@ColumnInfo(name = "best_snr")
val bestSnr: Float? = null,
@ColumnInfo(name = "best_rssi")
val bestRssi: Int? = null,
@ColumnInfo(name = "max_distance_m")
val maxDistanceMeters: Int? = null,
@ColumnInfo(name = "topology_edge_count")
val topologyEdgeCount: Int = 0,
@ColumnInfo(name = "notes")
val notes: String? = null,
)
```
### `DiscoveredNodeEntity`
```kotlin
@Entity(
tableName = "discovered_node",
foreignKeys = [
ForeignKey(
entity = DiscoverySessionEntity::class,
parentColumns = ["session_id"],
childColumns = ["session_id"],
onDelete = ForeignKey.CASCADE,
),
ForeignKey(
entity = DiscoveryPresetResultEntity::class,
parentColumns = ["preset_result_id"],
childColumns = ["preset_result_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [
Index(value = ["session_id"]),
Index(value = ["preset_result_id"]),
Index(value = ["node_num"]),
Index(value = ["preset_result_id", "node_num"], unique = true),
Index(value = ["has_valid_position"]),
],
)
data class DiscoveredNodeEntity(
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = "discovered_node_id")
val discoveredNodeId: Long = 0,
@ColumnInfo(name = "session_id")
val sessionId: String,
@ColumnInfo(name = "preset_result_id")
val presetResultId: Long,
@ColumnInfo(name = "node_num")
val nodeNum: Int,
@ColumnInfo(name = "user_id")
val userId: String? = null,
@ColumnInfo(name = "long_name")
val longName: String? = null,
@ColumnInfo(name = "short_name")
val shortName: String? = null,
@ColumnInfo(name = "first_seen_at")
val firstSeenAt: Long,
@ColumnInfo(name = "last_seen_at")
val lastSeenAt: Long,
@ColumnInfo(name = "packet_count")
val packetCount: Int = 0,
@ColumnInfo(name = "telemetry_count")
val telemetryCount: Int = 0,
@ColumnInfo(name = "neighbor_mention_count")
val neighborMentionCount: Int = 0,
@ColumnInfo(name = "best_snr")
val bestSnr: Float? = null,
@ColumnInfo(name = "best_rssi")
val bestRssi: Int? = null,
@ColumnInfo(name = "min_hops_away")
val minHopsAway: Int? = null,
@ColumnInfo(name = "battery_level")
val batteryLevel: Int? = null,
@ColumnInfo(name = "distance_m")
val distanceMeters: Int? = null,
@ColumnInfo(name = "latitude")
val latitude: Double? = null,
@ColumnInfo(name = "longitude")
val longitude: Double? = null,
@ColumnInfo(name = "has_valid_position", defaultValue = "0")
val hasValidPosition: Boolean = false,
@ColumnInfo(name = "saw_position", defaultValue = "0")
val sawPosition: Boolean = false,
@ColumnInfo(name = "saw_neighbor_info", defaultValue = "0")
val sawNeighborInfo: Boolean = false,
@ColumnInfo(name = "via_mqtt", defaultValue = "0")
val viaMqtt: Boolean = false,
)
```
## Relation Models
```kotlin
data class DiscoverySessionWithPresetResults(
@Embedded val session: DiscoverySessionEntity,
@Relation(
entity = DiscoveryPresetResultEntity::class,
parentColumn = "session_id",
entityColumn = "session_id",
)
val presetResults: List<DiscoveryPresetResultEntity> = emptyList(),
)
data class DiscoveryPresetResultWithNodes(
@Embedded val presetResult: DiscoveryPresetResultEntity,
@Relation(
entity = DiscoveredNodeEntity::class,
parentColumn = "preset_result_id",
entityColumn = "preset_result_id",
)
val nodes: List<DiscoveredNodeEntity> = emptyList(),
)
```
## DAO Interfaces
The project often uses `@Upsert`, but for this feature the baseline contract below uses `@Insert(onConflict = REPLACE)` plus focused `@Query` updates so the row lifecycle is explicit during scan progression.
### `DiscoverySessionDao`
```kotlin
@Dao
interface DiscoverySessionDao {
@Query("SELECT * FROM discovery_session ORDER BY started_at DESC")
fun observeSessions(): Flow<List<DiscoverySessionEntity>>
@Transaction
@Query("SELECT * FROM discovery_session WHERE session_id = :sessionId")
fun observeSession(sessionId: String): Flow<DiscoverySessionWithPresetResults?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(session: DiscoverySessionEntity)
@Query(
"""
UPDATE discovery_session
SET status = :status,
ended_at = :endedAt,
completed_preset_count = :completedPresetCount,
failed_preset_count = :failedPresetCount,
is_partial = :isPartial,
best_preset_key = :bestPresetKey,
recommendation_source = :recommendationSource,
recommendation_text = :recommendationText,
error_message = :errorMessage
WHERE session_id = :sessionId
""",
)
suspend fun completeSession(
sessionId: String,
status: String,
endedAt: Long,
completedPresetCount: Int,
failedPresetCount: Int,
isPartial: Boolean,
bestPresetKey: String?,
recommendationSource: String?,
recommendationText: String?,
errorMessage: String?,
)
@Delete
suspend fun delete(session: DiscoverySessionEntity)
}
```
### `DiscoveryPresetResultDao`
```kotlin
@Dao
interface DiscoveryPresetResultDao {
@Query(
"SELECT * FROM discovery_preset_result WHERE session_id = :sessionId ORDER BY preset_index ASC",
)
fun observePresetResults(sessionId: String): Flow<List<DiscoveryPresetResultEntity>>
@Transaction
@Query(
"SELECT * FROM discovery_preset_result WHERE preset_result_id = :presetResultId",
)
fun observePresetResult(presetResultId: Long): Flow<DiscoveryPresetResultWithNodes?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(result: DiscoveryPresetResultEntity): Long
@Query(
"""
UPDATE discovery_preset_result
SET status = :status,
ended_at = :endedAt,
actual_dwell_seconds = :actualDwellSeconds,
reconnect_count = :reconnectCount,
packet_count = :packetCount,
telemetry_count = :telemetryCount,
neighbor_info_count = :neighborInfoCount,
unique_node_count = :uniqueNodeCount,
median_snr = :medianSnr,
best_snr = :bestSnr,
best_rssi = :bestRssi,
max_distance_m = :maxDistanceMeters,
topology_edge_count = :topologyEdgeCount,
notes = :notes
WHERE preset_result_id = :presetResultId
""",
)
suspend fun finalizePresetResult(
presetResultId: Long,
status: String,
endedAt: Long?,
actualDwellSeconds: Int,
reconnectCount: Int,
packetCount: Int,
telemetryCount: Int,
neighborInfoCount: Int,
uniqueNodeCount: Int,
medianSnr: Float?,
bestSnr: Float?,
bestRssi: Int?,
maxDistanceMeters: Int?,
topologyEdgeCount: Int,
notes: String?,
)
@Delete
suspend fun delete(result: DiscoveryPresetResultEntity)
}
```
### `DiscoveredNodeDao`
```kotlin
@Dao
interface DiscoveredNodeDao {
@Query(
"SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId ORDER BY packet_count DESC, node_num ASC",
)
fun observeNodesForPreset(presetResultId: Long): Flow<List<DiscoveredNodeEntity>>
@Query(
"SELECT * FROM discovered_node WHERE session_id = :sessionId ORDER BY last_seen_at DESC",
)
fun observeNodesForSession(sessionId: String): Flow<List<DiscoveredNodeEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(node: DiscoveredNodeEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(nodes: List<DiscoveredNodeEntity>)
@Query("DELETE FROM discovered_node WHERE preset_result_id = :presetResultId")
suspend fun deleteNodesForPreset(presetResultId: Long)
@Delete
suspend fun delete(node: DiscoveredNodeEntity)
}
```
## Mermaid Relationship Diagram
```mermaid
erDiagram
DISCOVERY_SESSION ||--o{ DISCOVERY_PRESET_RESULT : contains
DISCOVERY_SESSION ||--o{ DISCOVERED_NODE : scopes
DISCOVERY_PRESET_RESULT ||--o{ DISCOVERED_NODE : records
DISCOVERY_SESSION {
string session_id PK
long started_at
long ended_at
string status
string home_preset_key
int selected_preset_count
int dwell_seconds
bool is_partial
string best_preset_key
}
DISCOVERY_PRESET_RESULT {
long preset_result_id PK
string session_id FK
int preset_index
string preset_key
string status
int packet_count
int telemetry_count
int neighbor_info_count
int unique_node_count
float median_snr
int max_distance_m
}
DISCOVERED_NODE {
long discovered_node_id PK
string session_id FK
long preset_result_id FK
int node_num
string long_name
long first_seen_at
long last_seen_at
int packet_count
float best_snr
int best_rssi
bool has_valid_position
}
```
## Validation Rules
1. **Session identity**
- `sessionId` must be globally unique.
- `displayName` must be non-blank.
- `homePresetKey` must be non-blank once the session leaves `PREPARING`.
2. **Session timing**
- `startedAt > 0`.
- `endedAt` must be `null` while status is non-terminal.
- If `endedAt != null`, then `endedAt >= startedAt`.
3. **Session counts**
- `selectedPresetCount >= 1`.
- `completedPresetCount + failedPresetCount <= selectedPresetCount`.
- `dwellSeconds >= 900` (15 minutes).
4. **Preset uniqueness and order**
- `presetIndex` must be unique per `sessionId`.
- `presetKey` must be unique per `sessionId`.
- `plannedDwellSeconds >= 900`.
- `actualDwellSeconds >= 0` and must not exceed the total recorded time span by more than a small timer tolerance.
5. **Metric monotonicity**
- All count fields are non-negative.
- `bestSnr`, `medianSnr`, and `bestRssi` may be null only when no relevant packet exists.
- `maxDistanceMeters >= 0` when present.
6. **Discovered node uniqueness**
- There may be only one `DiscoveredNodeEntity` per `(presetResultId, nodeNum)`.
- `firstSeenAt <= lastSeenAt`.
- `packetCount`, `telemetryCount`, and `neighborMentionCount` are non-negative.
7. **Position validity**
- `hasValidPosition` may be true only if latitude is within `[-90, 90]` and longitude is within `[-180, 180]`.
- `distanceMeters` may be non-null only if the local node and the discovered node both had valid positions at aggregation time.
## State Transitions
### Session status transitions
| From | To | Allowed | Notes |
|---|---|---:|---|
| `PREPARING` | `RUNNING` | Yes | After home preset snapshot and first preset dispatch. |
| `PREPARING` | `FAILED` | Yes | Validation failure, config-read failure, unsupported hardware. |
| `RUNNING` | `ANALYZING` | Yes | Final preset completed. |
| `RUNNING` | `CANCELLED` | Yes | User stop or radio switch. |
| `RUNNING` | `FAILED` | Yes | Non-recoverable reconnect / config failure. |
| `ANALYZING` | `COMPLETED` | Yes | Summary persisted. |
| `ANALYZING` | `CANCELLED` | No | Cancellation should be handled before entering analysis. |
| `COMPLETED` / `CANCELLED` / `FAILED` | any other state | No | Terminal states. |
### Preset-result status transitions
| From | To | Allowed | Notes |
|---|---|---:|---|
| `PENDING` | `SWITCHING` | Yes | Preset about to be applied. |
| `SWITCHING` | `WAITING_FOR_RECONNECT` | Yes | Admin message accepted and radio expected to bounce. |
| `WAITING_FOR_RECONNECT` | `DWELLING` | Yes | Connection stable and preset confirmed. |
| `DWELLING` | `COMPLETED` | Yes | Planned dwell elapsed. |
| `DWELLING` | `CANCELLED` | Yes | User stopped. |
| `WAITING_FOR_RECONNECT` | `FAILED` | Yes | Timeout or config mismatch. |
| `PENDING` | `SKIPPED` | Yes | Session cancelled before reaching this preset. |
| `COMPLETED` / `FAILED` / `CANCELLED` / `SKIPPED` | any other state | No | Terminal. |
## Query Patterns the UI Needs
1. **History list**: newest-first sessions with completed/partial badges.
2. **Session detail**: one `DiscoverySessionWithPresetResults` and all `DiscoveredNodeEntity` rows grouped by preset.
3. **Map tab**: session-scoped node query with optional preset filter and `hasValidPosition = 1` optimization.
4. **Summary tab**: preset-result rows ordered by `presetIndex` plus session-level recommendation fields.
5. **Delete flow**: single session delete, relying on FK cascade to remove related rows.
## MeshtasticDatabase Integration
When implemented:
1. Add the three entities to `MeshtasticDatabase.entities`.
2. Add the three DAO accessors to `MeshtasticDatabase`.
3. Bump the schema version to the next available value (`38 -> 39` at the time this spec was written).
4. Prefer auto-migration if only new tables / indices are added; introduce a manual migration spec only if backfill or data transforms are needed.
5. Extend migration and DAO tests in `core/database` to cover insert, relation loading, and cascade deletion.
## Recommended Repository Boundary
A `DiscoveryRepository` in `feature/discovery` or `core:data` should shield the UI from Room details. Typical responsibilities:
- start / stop / finalize session persistence
- upsert per-preset metrics
- aggregate packet observations into `DiscoveredNodeEntity`
- expose `Flow` APIs for history, session detail, summary, and map filters
This keeps Room-specific code out of screen/viewmodel classes while preserving a testable, KMP-friendly domain model.

View File

@@ -0,0 +1,289 @@
# Implementation Plan — Local Mesh Discovery
## Overview
Implement Local Mesh Discovery as a new KMP feature module under `feature/discovery/`. The module owns the shared scan state machine, persistence-facing repository, summary UI, and map/topology presentation models. Android and Desktop hosts provide thin platform integrations for maps, export, and (on supported Google-flavor Android devices) Gemini Nano.
## Technical Context
| Area | Choice |
|---|---|
| Language | Kotlin 2.3+ |
| UI | Compose Multiplatform + Material 3 Adaptive |
| Navigation | JetBrains Navigation 3 typed `NavKey` routes |
| DI | Koin 4.2+ K2 compiler plugin (`@Module`, `@ComponentScan`, `@KoinViewModel`) |
| Persistence | Room KMP in `core:database` |
| Radio config mutation | Existing admin/config path reused from `feature:settings` |
| Packet capture | Existing mesh packet pipeline / repositories / handlers |
| Preferences | DataStore via `core:prefs` + `core:repository` interfaces |
| Maps | CompositionLocal provider pattern (`MapViewProvider`, inline/overlay locals) |
| AI | Gemini Nano via Google AI Edge SDK on supported Google flavor Android; deterministic fallback everywhere else |
| Logging | Kermit `Logger` + optional MeshLog-backed debugging where appropriate |
| Build | Gradle Kotlin DSL + convention plugins in `build-logic/` |
## Module Structure
```text
feature/discovery/
├── build.gradle.kts
└── src/
├── commonMain/
│ └── kotlin/org/meshtastic/feature/discovery/
│ ├── di/
│ │ └── FeatureDiscoveryModule.kt
│ ├── navigation/
│ │ └── DiscoveryNavigation.kt
│ ├── model/
│ │ ├── DiscoverySession.kt
│ │ ├── DiscoveryPresetSummary.kt
│ │ └── DiscoveryNodeObservation.kt
│ ├── repository/
│ │ └── DiscoveryRepository.kt
│ ├── scan/
│ │ ├── DiscoveryScanCoordinator.kt
│ │ ├── DiscoveryScanState.kt
│ │ ├── DiscoveryPacketCollector.kt
│ │ └── DiscoveryRankingEngine.kt
│ ├── ui/
│ │ ├── LocalMeshDiscoveryScreen.kt
│ │ ├── DiscoveryHistoryScreen.kt
│ │ ├── DiscoverySummaryScreen.kt
│ │ └── DiscoveryMapScreen.kt
│ └── DiscoveryViewModel.kt
├── androidMain/
│ └── kotlin/org/meshtastic/feature/discovery/
│ ├── ai/AndroidDiscoveryRecommendationEngine.kt
│ ├── export/AndroidDiscoveryExporter.kt
│ └── map/AndroidDiscoveryMapBindings.kt
├── jvmMain/
│ └── kotlin/org/meshtastic/feature/discovery/
│ ├── export/DesktopDiscoveryExporter.kt
│ └── map/DesktopDiscoveryMapBindings.kt
└── commonTest/
└── kotlin/org/meshtastic/feature/discovery/
├── DiscoveryScanCoordinatorTest.kt
├── DiscoveryRankingEngineTest.kt
└── DiscoveryViewModelTest.kt
```
## Project Structure Changes Outside the Module
| Area | Planned Change |
|---|---|
| `settings.gradle.kts` | Include `:feature:discovery` |
| `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` | Include `FeatureDiscoveryModule` |
| `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt` | Include generated discovery Koin module |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` | Add typed discovery settings routes |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` | Add `/settings/local-mesh-discovery` mapping + optional compatibility alias |
| `core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt` | Add discovery deep-link coverage |
| `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt` or related settings screen | Add entry point under Advanced section |
| `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt` | Register new discovery entities / DAOs and bump schema version |
| `core/resources/src/commonMain/composeResources/values/strings.xml` | Add all discovery UI strings |
| `app` / `desktop` map bindings | Provide discovery map adapter if a specialized provider is needed |
## Build-Logic and Gradle Plan
### Module build script
Start from the `feature/map` pattern and extend only as needed:
```kotlin
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
```
### Expected dependencies
`commonMain` likely needs:
- `projects.core.common`
- `projects.core.data`
- `projects.core.database`
- `projects.core.di`
- `projects.core.model`
- `projects.core.navigation`
- `projects.core.prefs`
- `projects.core.repository`
- `projects.core.resources`
- `projects.core.ui`
- `libs.jetbrains.navigation3.ui`
- `libs.kotlinx.collections.immutable` (if chip/filter state mirrors other features)
`androidMain` may add the Android AI/export integration dependency set. Keep flavor- or provider-specific dependencies out of `commonMain`.
### Convention reminders
- Keep shared business logic in `commonMain`.
- Do not import Android framework APIs in shared code.
- Use shared strings from `core/resources`.
- Use `MeshtasticIcons` for all new icons.
- Use `safeCatching {}` in coroutine code where failures are expected.
## Navigation Integration Plan
### Route shape
Use the Settings family for external entry, with optional detail routes for history:
- `SettingsRoute.LocalMeshDiscovery`
- `SettingsRoute.LocalMeshDiscoverySession(sessionId: String)`
### Graph integration
1. Add typed routes in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`.
2. Register route slug(s) in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt`.
3. Add discovery entries inside `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` or call into a discovery-owned extension from there.
4. Add a settings list item under the existing **Advanced** section.
### Deep-link shape
- Canonical: `meshtastic://meshtastic/settings/local-mesh-discovery`
- Compatibility alias: `meshtastic://meshtastic/settings/localMeshDiscovery`
- Session detail: `meshtastic://meshtastic/settings/local-mesh-discovery/session/{sessionId}`
## Persistence Plan
### Database integration
- Add `DiscoverySessionEntity`, `DiscoveryPresetResultEntity`, and `DiscoveredNodeEntity` to `core:database`.
- Add DAOs and accessors to `MeshtasticDatabase`.
- Bump Room schema to the next available version.
- Add common DAO tests plus migration coverage.
### Repository boundary
Implement a discovery repository layer that:
- starts and finalizes sessions
- records preset transitions
- aggregates incoming packet observations into per-preset metrics
- exposes `Flow` APIs for history and detail screens
This repository can live in `feature/discovery` if the scope remains feature-local, or move to `core:data` if broader reuse appears.
## Scan Engine Plan
### Orchestration responsibilities
`DiscoveryScanCoordinator` in `commonMain` should:
1. validate selected presets and dwell time
2. resolve hardware gating
3. snapshot the current home preset
4. create a session row and per-preset placeholder rows
5. dispatch preset changes through the existing config path
6. wait for reconnect stability
7. start / pause / resume dwell timing
8. collect metrics during the dwell window
9. restore the home preset after completion / stop / failure
10. finalize summary + recommendation fields
### Dependencies the coordinator will need
- radio config mutation interface
- connection-state flow
- packet / node / neighbor info sources
- clock / timer abstraction for tests
- discovery repository
- optional recommendation engine
## Map Visualization Plan
### Shared side
In `commonMain`, build:
- preset filter state
- mapped/unmapped node counts
- node marker presentation models
- topology edge models derived from neighbor info
- selected node detail model
### Platform side
- Android should reuse existing map provider infrastructure and only add discovery-specific overlays if needed.
- Desktop should either wire a provider or explicitly use the same placeholder/list fallback pattern seen elsewhere.
- No map SDK types should leak into shared code.
## AI Recommendation Plan
### Shared contract
```text
interface DiscoveryRecommendationEngine {
suspend fun recommend(summary: DiscoverySessionSummary): DiscoveryRecommendationResult
}
```
### Implementations
- `RuleBasedDiscoveryRecommendationEngine` in `commonMain` (always available)
- `AndroidDiscoveryRecommendationEngine` in `androidMain` (Google flavor only, Gemini Nano capable devices only)
- No-op / fallback binding on unsupported targets
> **Cross-spec note (F3):** Feature 003 (App Documentation) defines a parallel `AIDocAssistant` interface with the same platform-gating and fallback pattern. The two abstractions are intentionally separate because their prompts, result types, and domain contexts differ significantly. If a third AI-powered feature is added, a shared `core:ai` capability-check and session-factory module should be extracted to avoid further duplication.
### Prompting strategy
Pass only compact structured metrics into the AI layer:
- preset order and names
- counts and rankings
- noteworthy caveats (failed reconnect, partial dwell, no neighbor info)
- best/worst metrics
Do not pass raw packet payload dumps.
## Preferences Plan
Add a discovery prefs contract to `core:repository` and `core:prefs` for lightweight UI defaults such as:
- last dwell duration
- last selected preset set
- last-used map filter
- whether AI expansion is enabled / preferred
- whether topology overlay defaults on
Long-lived session history belongs in Room, not DataStore.
## Testing Strategy
### Module-level validation
- `:feature:discovery:allTests` for shared logic and ViewModels
- `:core:database:allTests` for DAO logic if schema work lands there
- `:app:testFdroidDebugUnitTest` and/or `:app:testGoogleDebugUnitTest` when Android route wiring or export integration changes
- `./gradlew kmpSmokeCompile` after route / source-set wiring
### Test focus areas
- scan state machine transitions
- reconnect pause/resume behavior
- partial cancellation and home-preset restore logic
- ranking heuristic tie-breakers
- AI fallback behavior
- Room relation loading and cascade deletion
- deep-link routing
## Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Radio reconnect takes longer than expected | Keep reconnect timeout configurable and lean on existing transport behavior rather than custom retries. |
| Hardware capability data is incomplete | Default 2.4 GHz presets to disabled when capability cannot be verified. |
| AI path is unavailable on most devices | Treat AI as an optional enhancement; deterministic summary remains first-class. |
| Map overlays become platform-specific too early | Keep all overlay calculations in `commonMain` and push only rendering to platform code. |
| Session rows grow large over time | Normalize tables and store only aggregate/session reconstruction data, not every raw packet. |
## Delivery Order
1. Set up the feature module, navigation entry, and DI wiring.
2. Land Room entities / DAOs / migration.
3. Implement the scan coordinator and persistence.
4. Add map + summary UI.
5. Add history and export.
6. Add optional Gemini Nano integration.
This order keeps the feature demonstrable early and ensures the optional AI work cannot block the main diagnostic experience.

View File

@@ -0,0 +1,130 @@
# Quickstart — Local Mesh Discovery
## Purpose
This guide helps a Meshtastic-Android contributor bootstrap, navigate, test, and debug the Local Mesh Discovery feature work.
## Prerequisites
- **JDK 21**
- **Android SDK** installed and `ANDROID_HOME` available to Gradle
- **Git submodule initialized** for `core/proto`
- A working `local.properties` file (copy from `secrets.defaults.properties` if needed)
- A Meshtastic radio for end-to-end testing of preset switching and reconnect behavior
## Workspace Bootstrap
Run these commands from the repository root:
```bash
git submodule update --init
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
If `ANDROID_HOME` is not already set, use the standard workspace bootstrap logic documented in `.skills/project-overview/SKILL.md`.
## Feature Access Path
Once implemented, the feature entry point should be:
- **In-app**: `Settings > Advanced > Local Mesh Discovery`
- **Canonical deep link**: `meshtastic://meshtastic/settings/local-mesh-discovery`
- **Compatibility alias**: `meshtastic://meshtastic/settings/localMeshDiscovery`
## Recommended Development Commands
### KMP / feature-focused
```bash
./gradlew :feature:discovery:allTests
./gradlew :core:database:allTests
./gradlew kmpSmokeCompile
```
### Android host wiring
```bash
./gradlew :app:testFdroidDebugUnitTest
./gradlew :app:testGoogleDebugUnitTest
./gradlew lintFdroidDebug lintGoogleDebug
```
### Full verification
```bash
./gradlew spotlessApply detekt assembleDebug test allTests
```
> Both `test` and `allTests` are required in this repo. `allTests` covers KMP modules; `test` covers pure Android modules.
## Key Files
| Path | Why it matters |
|---|---|
| `specs/001-local-mesh-discovery/spec.md` | Primary feature specification |
| `specs/001-local-mesh-discovery/data-model.md` | Discovery Room KMP schema design |
| `feature/discovery/build.gradle.kts` | New feature module build definition |
| `feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/scan/DiscoveryScanCoordinator.kt` | Shared scan orchestration state machine |
| `feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt` | Screen state + user actions |
| `feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt` | Navigation 3 entry registration |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` | Typed route definitions |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` | Deep-link path mapping |
| `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt` | Room KMP schema registration |
| `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt` | Discovery session persistence entity |
| `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfig.kt` | Settings > Advanced entry point |
| `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt` | Existing topology capture hook |
| `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt` | Existing reconnect behavior discovery depends on |
## Logging and Diagnostics
Use the existing logging stack rather than inventing a feature-local logger.
### Suggested tags/classes to instrument
- `DiscoveryScanCoordinator`
- `DiscoveryPacketCollector`
- `DiscoveryRepository`
- `DiscoveryRankingEngine`
- `AndroidDiscoveryRecommendationEngine`
### Where to inspect logs
- **Android**: `adb logcat` and the existing in-app Debug Panel (`Settings > Advanced > Debug Panel`) if mesh logging is enabled.
- **Desktop/JVM**: stdout / IDE console plus Kermit-backed logs.
### Suggested Android logcat filter
```bash
adb logcat | grep -E "Discovery|BleRadioTransport|BleReconnectPolicy|NeighborInfo"
```
## Manual End-to-End Test Loop
1. Connect to a radio over BLE.
2. Open `Settings > Advanced > Local Mesh Discovery`.
3. Select one supported preset and minimum dwell time.
4. Start scan and verify:
- home preset snapshot succeeds
- preset change is dispatched
- reconnect wait state appears if the radio reboots
- dwell countdown begins only after reconnect
5. Stop the scan early and verify partial session persistence + home preset restore.
6. Re-open history and confirm the session is visible without reconnecting.
## Common Pitfalls
- Forgetting `allTests` means KMP tests may not run.
- Forgetting to initialize the proto submodule breaks builds unrelated to discovery logic.
- Using direct Android map or AI APIs in `commonMain` will violate KMP boundaries.
- Treating 2.4 GHz capability as always known is unsafe; unknown must default to blocked.
- Letting dwell time run during reconnect will corrupt results.
## Done Definition for This Feature
Before calling the feature done locally:
1. Room schema and DAO tests pass.
2. Scan engine tests cover cancel/fail/restore behavior.
3. Discovery routes deep-link correctly.
4. Android host tests pass for changed wiring.
5. Full repo verification command passes.

View File

@@ -0,0 +1,227 @@
# Research — Local Mesh Discovery
This document captures the main technical decisions for implementing Local Mesh Discovery in Meshtastic-Android.
## R-001 — NeighborInfo packet processing
### Decision
Reuse the existing `NeighborInfo` packet pipeline instead of introducing a discovery-specific parser.
### Evidence in current codebase
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt` already routes specialized packet types.
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt` decodes and stores the latest local-radio `NeighborInfo` payload.
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt` already exposes `requestNeighborInfo(requestId, destNum)` and records request timing through `NeighborInfoHandler.recordStartTime(...)`.
- `core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt` already contains domain helpers for decoding / formatting neighbor info.
### Implementation guidance
- Local Mesh Discovery should subscribe to the same packet stream already used by the rest of the app.
- At the start and/or end of each dwell, the scan engine should request neighbor info from the local radio using the existing command path so topology data is captured even when no spontaneous neighbor report arrives during that dwell.
- The feature should persist both the aggregate count (`neighborInfoCount`) and node-level neighbor mentions (`neighborMentionCount`) so the map and summary can reconstruct topology richness.
### Why this was chosen
This minimizes risk, avoids duplicated protobuf decoding, and guarantees discovery data matches the rest of the apps interpretation of `NeighborInfo` packets.
### Consequences
- Discovery does not need a new protobuf or low-level radio hook.
- The feature must tolerate the fact that neighbor info may be absent for some presets and degrade gracefully.
---
## R-002 — Radio reboot detection after preset change
### Decision
Treat preset switching as a high-level state-machine concern and rely on the existing BLE transport stack for reconnect behavior.
### Evidence in current codebase
- `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt` already implements exponential backoff and transient/permanent disconnect signaling.
- `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt` configures the reconnect policy with effectively infinite retry while the selected device remains active.
- Settings already mutate radio config through the existing admin/config path; discovery can reuse that mechanism rather than creating a separate transport loop.
### Implementation guidance
- After dispatching a preset change, Local Mesh Discovery should move to `WaitingForReconnect` and observe shared connection state (for example through `ServiceRepository` / `RadioController`-backed flows already used by the app).
- Dwell time must not begin until the connection is stable again and the feature has enough confirmation that the new preset is active.
- If reconnect never stabilizes within the feature timeout, mark the preset failed, persist partial data, and attempt home-preset restoration.
### Why this was chosen
BLE reconnect behavior is already one of the more sensitive parts of the codebase. Reusing the existing policy keeps discovery from fighting the transport layer.
### Consequences
- Discovery should not create its own reconnect coroutine or scanner loop.
- Session UX must clearly distinguish between “waiting for reconnect” and “actively dwelling”.
---
## R-003 — On-device AI recommendation
### Decision
Define a shared recommendation-engine contract in `commonMain`, use **Gemini Nano via Google AI Edge SDK** only on supported Google-flavor Android devices, and always ship a deterministic fallback.
### Rationale
Gemini Nano is the preferred on-device AI for recommendations, but it is not universally available:
- requires supported Android hardware / OS
- is not appropriate for all flavors (especially `fdroid`)
- can fail due to model availability, permissions, or runtime support
### Implementation guidance
- Create a `DiscoveryRecommendationEngine` interface in `commonMain`.
- Create a deterministic `RuleBasedDiscoveryRecommendationEngine` in `commonMain` and make it the default.
- Add an Android Google-flavor adapter that wraps Google AI Edge SDK when the device, flavor, and runtime environment support Gemini Nano.
- Feed AI only a summarized session payload (counts, rankings, caveats), not raw packet blobs.
- Keep AI opt-in and non-blocking. The session summary must remain usable even when AI is unavailable.
### Why this was chosen
This keeps the feature fully functional without requiring proprietary or hardware-specific AI support.
### Consequences
- AI support becomes an enhancement, not a hard dependency.
- The deterministic ranking engine must be well-designed because it is always the fallback and often the primary path.
---
## R-004 — Navigation integration
### Decision
Integrate Local Mesh Discovery into the existing Settings flow with typed Navigation 3 routes and deep-link support in `DeepLinkRouter`.
### Evidence in current codebase
- `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` defines shared route families as `@Serializable sealed interface` hierarchies.
- `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` assembles settings destinations inside `fun EntryProviderScope<NavKey>.settingsGraph(...)`.
- `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` already maps `/settings/...` sub-paths to typed settings routes.
- `MainActivity` already routes `meshtastic://meshtastic/...` deep links through the shared router.
### Implementation guidance
- Add a typed settings route such as `SettingsRoute.LocalMeshDiscovery` for the main entry point.
- Add a second typed route for session detail if history entries need a stable deep-link target, for example `SettingsRoute.LocalMeshDiscoverySession(sessionId: String)`.
- Extend `SettingsNavigation.settingsGraph(...)` with discovery entries.
- Extend `DeepLinkRouter.settingsSubRoutes` and corresponding tests.
### Why this was chosen
This matches Meshtastic-Androids Navigation 3 architecture, preserves typed backstack persistence, and keeps discovery reachable from app links and notifications in the same way as existing features.
### Consequences
- The canonical deep link should follow existing hyphenated path conventions
- Discovery navigation tests should be added beside the existing `DeepLinkRouterTest` and `NavigationConfigTest` coverage.
---
## R-005 — 2.4 GHz hardware gating
### Decision
Resolve 2.4 GHz capability through `DeviceHardwareRepository` plus current-radio metadata instead of hardcoding a device list inside the feature.
### Evidence in current codebase
- `core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceHardwareRepository.kt` provides `getDeviceHardwareByModel(hwModel, target, forceRefresh)`.
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt` already supports cache, remote fetch, bundled JSON fallback, and target-based disambiguation.
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceHardwareEntity.kt` stores `hwModel`, `platformioTarget`, `hwModelSlug`, and `tags`.
- `MyNodeEntity` already stores `pioEnv`, which can help target disambiguation.
### Implementation guidance
- Use current-radio hardware model plus the best available target hint (`pioEnv`, reported target, or similar metadata) to query `DeviceHardwareRepository`.
- Determine 2.4 GHz support using a layered heuristic:
1. explicit capability tags if the hardware dataset gains them,
2. known target / slug patterns such as `sx1280`, `2.4`, or `2400`,
3. safe default of **unsupported / unknown** when evidence is insufficient.
- Keep the gating logic in a shared use case so it can be unit tested.
### Why this was chosen
The project already has a hardware metadata pipeline. Reusing it keeps the capability logic consistent with other hardware-aware features.
### Consequences
- Capability detection quality depends on the fidelity of hardware metadata.
- The UI must explain “unsupported” vs “unable to verify” clearly.
---
## R-006 — Map rendering strategy
### Decision
Build discovery map state in `commonMain` and render it through the same CompositionLocal provider pattern already used by the app map feature.
### Evidence in current codebase
- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt` defines `LocalMapViewProvider`.
- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalInlineMapProvider.kt`, `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalNodeTrackMapProvider.kt`, and `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalTracerouteMapProvider.kt` show how shared UI asks the host for platform-specific map rendering.
- `feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt` already exposes node and map filter state in a KMP-friendly way.
- `MainActivity` provides Android map implementations through CompositionLocals.
### Implementation guidance
- Keep session filtering, marker grouping, topology edge preparation, and unmapped-node accounting in `commonMain`.
- Reuse or mirror the existing map-provider abstraction rather than importing Google Maps or OSM APIs into shared code.
- On Desktop/JVM, support either a desktop provider or a placeholder/list fallback. The feature must not assume Android map APIs exist.
### Why this was chosen
This matches the current app architecture and prevents discovery from becoming flavor- or platform-locked.
### Consequences
- Discovery may need a small feature-specific map adapter if its overlay needs differ from the main live map.
- Exported PDF map snapshots are platform-specific and may need graceful fallback.
---
## R-007 — Room KMP schema and migration strategy
### Decision
Add new discovery tables to the existing `MeshtasticDatabase` and version them through the normal Room KMP migration path.
### Evidence in current codebase
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt` is the single Room KMP database for persisted radio/app data.
- The project already uses incremental Room versions and `AutoMigration` where possible.
- `DatabaseProvider` / `DatabaseManager` already scope databases per connected device, which is a natural fit for discovery history.
### Implementation guidance
- Add the new entities and DAOs to `MeshtasticDatabase`.
- Bump to the next schema version (`38 -> 39` at the time of writing; use the next available number if this changes before implementation).
- Prefer auto-migration if the change is additive (new tables + indices only).
- Add DAO and migration tests in `core/database` to cover inserts, relation loading, and cascade deletion.
- Keep discovery in the same per-device DB so history automatically follows the connected radio context.
### Why this was chosen
A second database would complicate lifecycle, backup/export, and switching between radios. The existing DB manager already solves those concerns.
### Consequences
- Historical discovery sessions are scoped to the radio/device DB they were recorded under.
- Export becomes the mechanism for cross-device sharing.
---
## Recommended follow-up decisions during implementation
1. Finalize the exact 2.4 GHz capability heuristic once hardware JSON coverage is reviewed.
2. Decide whether discovery needs its own route family or can live entirely inside `SettingsRoute`.
3. Decide whether Android PDF export should capture a rendered map bitmap or fall back to summary-only PDF in v1.
4. Confirm whether Desktop will ship a discovery map provider in the first implementation or a placeholder/list fallback.

View File

@@ -0,0 +1,361 @@
# Feature Specification: Local Mesh Discovery
**Feature Branch**: `001-local-mesh-discovery`
**Created**: 2026-05-07
**Status**: Not Started
**Input**: User description: "Local Mesh Discovery — a high-fidelity diagnostic and community-mapping tool that cycles through modem presets to audit the local RF environment"
## Summary
Local Mesh Discovery is a shared KMP feature for Meshtastic-Android that runs a controlled multi-preset RF scan against the currently connected radio, captures what the mesh looks like under each LoRa modem preset, and presents the results as maps, tables, topology overlays, and best-preset recommendations.
The implementation aligns with Meshtastic-Android's KMP architecture:
- **Persistence** uses **Room KMP** in `core:database`.
- **UI** uses **Compose Multiplatform + Material 3 Adaptive**.
- **Navigation** uses **Navigation 3 typed `NavKey` routes**.
- **Preferences** use **DataStore via `core:prefs`**.
- **Distance and metric formatting** use **`MetricFormatter` / shared formatters**.
- **Maps** use the existing **CompositionLocal provider pattern** (`LocalMapViewProvider`, `LocalInlineMapProvider`, related map locals).
- **AI recommendations** use **Gemini Nano through Google AI Edge SDK on supported Google-flavor Android devices**, with a deterministic structured fallback everywhere else.
- **Reconnect handling** reuses the existing **`BleReconnectPolicy` / `BleRadioTransport`** stack rather than introducing feature-specific BLE recovery logic.
## Goals
1. Let users compare local mesh visibility across one or more modem presets without manually editing LoRa settings between runs.
2. Persist results per radio in the existing Room KMP database so sessions can be reviewed later without reconnecting.
3. Present results in a way that is useful on Android and Desktop, while keeping all decision-making and aggregation logic in `commonMain`.
4. Reuse existing Meshtastic-Android infrastructure for packet capture, config mutation, BLE recovery, maps, logging, formatting, navigation, and DI.
5. Keep 2.4 GHz presets safely gated so unsupported hardware cannot start invalid scans.
## Non-Goals
- Replacing the standard map feature or node list.
- Creating a cloud-synced discovery history.
- Running AI analysis through a network service.
- Modifying firmware behavior or protobuf definitions.
- Guaranteeing discovery survives full process death; partial results are persisted instead.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Configure and Run a Multi-Preset Scan (Priority: P1)
A Meshtastic user wants to discover what nodes and activity exist in their local area across different LoRa modem presets. In Meshtastic-Android they open **Settings > Advanced > Local Mesh Discovery**, select one or more presets (for example `LongFast` and `MediumFast`), set a dwell time per preset, and tap **Start Scan**. The app cycles through each preset by sending the same LoRa config change path already used by `feature:settings`, waiting for the radio to become available again if it reboots, dwelling for the configured time while collecting packets, and then advancing to the next preset. The user sees which preset is active, how much dwell time remains, and whether the app is waiting for reconnect or analyzing results.
**Why this priority**: Without the scan engine there is no feature. This is the minimum viable product and all later visualization and recommendation work depends on these captured results.
**Independent Test**: Connect to a radio, select one preset, set the minimum dwell time (15 minutes), start the scan, and verify the radio changes preset, reconnects if necessary, and collects node / telemetry / neighbor data during the dwell window.
**Acceptance Scenarios**:
1. **Given** the user is connected to a Meshtastic radio, **When** they select two presets with a 15-minute dwell and tap Start Scan, **Then** the app snapshots the current home preset, sends an admin config change for the first selected preset, waits for the radio to become available, begins the dwell timer, and starts collecting incoming packets.
2. **Given** the radio reboots after a preset change, **When** the BLE connection drops, **Then** the app relies on `BleReconnectPolicy` / `BleRadioTransport` to reconnect automatically and resumes the same preset only after the connection is stable again.
3. **Given** a preset dwell window completes, **When** the timer expires, **Then** the app advances to the next preset, updates progress state, and repeats the switch / reconnect / dwell cycle.
4. **Given** all presets complete successfully, **When** the final dwell ends, **Then** the app transitions to an Analysis state, persists the session in Room KMP, and opens the session summary.
5. **Given** a scan is in progress, **When** the user taps Stop Scan, **Then** the scan halts gracefully, partial results are saved, and the users original home preset is restored.
---
### User Story 2 — Visualize Discovered Nodes on a Map (Priority: P2)
After a scan, a user wants to understand where discovered nodes were seen and how coverage differs by preset. They open a completed discovery session and switch to a map tab that uses the platform map provider already exposed through CompositionLocals. Nodes with valid positions appear as markers; preset chips filter the map; and, when neighbor information is available, the user can overlay simple topology edges to see who reported whom.
**Why this priority**: The core reason to run discovery is to understand local RF reach and mesh topology, not just raw packet counts. A map and topology view make the data actionable.
**Independent Test**: Complete a session that discovers at least two nodes with valid positions, open the session map on both `fdroid` and `google` Android flavors, switch preset filters, and verify that markers and optional topology overlays update correctly. On JVM/Desktop, verify a safe placeholder or supported provider path is shown instead of a crash.
**Acceptance Scenarios**:
1. **Given** a stored discovery session contains nodes with valid positions, **When** the user opens the Map tab, **Then** the app renders those nodes using the current platform map provider and shows a count of mapped vs unmapped nodes.
2. **Given** a node was discovered only on one preset, **When** the user filters by another preset, **Then** that node is hidden from the filtered view while session totals remain available elsewhere in the UI.
3. **Given** the session contains captured `NeighborInfo` relationships, **When** the user enables topology overlay, **Then** the app draws reporter-to-neighbor edges for nodes that have valid map coordinates.
4. **Given** the current target does not have a native map implementation available, **When** the user opens the Map tab, **Then** the app falls back to a placeholder or list-based presentation instead of failing.
5. **Given** the user taps a node marker or node card, **When** the detail sheet opens, **Then** the UI shows preset-specific metrics such as SNR, RSSI, hops, last heard, and distance from the local node when both positions are known.
---
### User Story 3 — Review Scan Summary and AI Recommendation (Priority: P3)
A user wants the app to summarize which preset performed best and why. After a session, the app presents per-preset metrics, rankings, and charts. If the app is running on the Google flavor of Android 14+ hardware that supports Gemini Nano through Google AI Edge SDK, the user can request an on-device narrative recommendation. On unsupported hardware, non-Google builds, desktop targets, or when the model is unavailable, the app shows a structured comparison table and deterministic recommendation instead.
**Why this priority**: Discovery results are only useful if the user can quickly interpret them and decide which preset to keep as their everyday operating mode.
**Independent Test**: Complete a session with at least two presets, open the summary on a supported Google-flavor Android device and on an unsupported or non-Google target, and verify that both the AI path and fallback path produce a usable recommendation without blocking the rest of the summary.
**Acceptance Scenarios**:
1. **Given** a completed discovery session, **When** the user opens the Summary tab, **Then** the app shows per-preset totals including unique node count, packet count, telemetry count, neighbor reports, best / median link quality, and best known distance.
2. **Given** Gemini Nano is available and the user explicitly requests analysis, **When** the recommendation engine runs, **Then** the app produces an on-device summary that ranks presets, explains tradeoffs, and does not send session data off-device.
3. **Given** Gemini Nano is unavailable, unsupported, disabled, or errors, **When** the Summary tab loads, **Then** the app shows the deterministic fallback recommendation and a structured table instead of an AI error screen.
4. **Given** a session is partial because the user stopped early or one preset failed, **When** the summary is generated, **Then** incomplete presets are marked clearly and are excluded from “best preset” calculations unless the user opts to inspect them manually.
5. **Given** two presets tie on the primary ranking metric, **When** the recommendation is computed, **Then** tie-breakers use additional metrics in a documented order and the UI explains the tie outcome.
---
### User Story 4 — Persist and Review Past Sessions (Priority: P4)
A user wants to compare todays environment with older discovery runs. Each session is stored in Room KMP in the same per-device database system already managed by `DatabaseProvider`. The user can review a history list, open an old session without reconnecting to a radio, share or export the session summary, and delete old sessions when they are no longer needed.
**Why this priority**: Discovery is most useful over time. Users need historical comparisons instead of a single ephemeral run.
**Independent Test**: Run a discovery session, force-close and relaunch the app, open discovery history, verify the stored session is still available, delete it, and verify its related rows are removed.
**Acceptance Scenarios**:
1. **Given** a scan finishes or is stopped, **When** persistence completes, **Then** the session, per-preset result rows, and discovered node rows are stored in the active Room KMP database.
2. **Given** the app restarts, **When** the user opens Local Mesh Discovery history, **Then** previously stored sessions load without requiring an active radio connection.
3. **Given** the user opens a historical session, **When** the summary screen appears, **Then** the same tabs and filters work against persisted data instead of live packet flows.
4. **Given** the user deletes a session, **When** deletion is confirmed, **Then** related preset-result and discovered-node rows are removed atomically.
5. **Given** the user shares or exports a stored session, **When** the export completes, **Then** Android uses a Share / document flow and Desktop uses a local save flow, with a PDF-first result when map capture is available and a text / table fallback when it is not.
---
### User Story 5 — 2.4 GHz Preset Gating (Priority: P5)
A user must not be allowed to choose 2.4 GHz discovery presets on unsupported hardware. The app inspects current radio hardware metadata using the existing `DeviceHardwareRepository` and the radios reported model / target info, then enables or disables 2.4 GHz presets accordingly. If capability cannot be verified, the presets stay disabled with an explanation.
**Why this priority**: Invalid preset selection creates confusing failures, unnecessary reboots, and poor trust in the feature. Safe hardware gating avoids avoidable radio errors.
**Independent Test**: Connect to a radio known not to support 2.4 GHz and verify 2.4 GHz presets are disabled. Connect to a supported radio and verify those presets are selectable.
**Acceptance Scenarios**:
1. **Given** the connected radio does not support 2.4 GHz operation, **When** the preset selector is shown, **Then** 2.4 GHz presets are hidden or disabled with an explanatory message.
2. **Given** the connected radio does support 2.4 GHz operation, **When** the preset selector is shown, **Then** the user can include those presets in the scan queue.
3. **Given** hardware capability lookup fails or returns ambiguous results, **When** the screen loads, **Then** 2.4 GHz presets remain disabled and the UI explains that capability could not be verified.
4. **Given** a previously saved session references a 2.4 GHz preset but the currently connected device does not support it, **When** the user tries to rerun that session template, **Then** the app blocks start and explains which presets are incompatible.
## Scope Boundaries
### In Scope
- Scan orchestration across one or more modem presets.
- Reuse of the existing admin config path for changing LoRa presets.
- Reuse of existing packet handling for node, telemetry, position, text/activity, and neighbor info packets.
- Session persistence via Room KMP.
- Session history, summary, map, export/share, and deterministic recommendation.
- Optional on-device AI recommendation on supported Android Google-flavor devices.
- Android and Desktop host integration, with compile-safe KMP behavior for other targets.
### Out of Scope
- Firmware-side changes to emit new packet types.
- Cloud backup or cross-device sync.
- Automatically changing the users home preset without explicit restore-on-stop / restore-on-finish behavior.
- A fully background-resilient WorkManager / foreground-service scan scheduler in the first version.
## Functional Requirements
### Scan Configuration and Orchestration
- **FR-001**: The feature shall live in a new KMP module at `feature/discovery/` using the `meshtastic.kmp.feature` convention plugin.
- **FR-002**: The feature shall be reachable from the Settings flow through a typed Navigation 3 route in `SettingsGraph`.
- **FR-003**: The user shall be able to select **one or more** modem presets for a session; the UI should encourage multiple presets but not require more than one.
- **FR-004**: The user shall be able to configure a dwell time per preset with a minimum of 15 minutes.
- **FR-005**: Before the first preset change, the app shall snapshot the currently active home preset so it can be restored when the scan ends, is cancelled, or fails after at least one successful change.
- **FR-006**: The scan engine shall mutate modem presets using the existing admin/config path already used by `feature:settings` for `Config.LoRaConfig` updates.
- **FR-007**: The scan engine shall not advance from preset-switching to dwelling until the radio is reconnected and stable.
- **FR-008**: The scan engine shall expose state as a deterministic state machine with states for idle, preparing, switching, waiting for reconnect, dwelling, analyzing, completed, cancelled, and failed.
- **FR-009**: The user shall be able to stop a scan at any time; partial results must be preserved.
- **FR-010**: If preset restoration fails at the end of a run, the session shall still be saved and the UI shall display a recoverable warning.
### Packet Collection and Metrics
- **FR-011**: Packet collection shall reuse the existing packet pipeline (`ServiceRepository.meshPacketFlow`, repositories, handlers) rather than adding a parallel decoder.
- **FR-012**: The feature shall collect enough data to compute per-preset metrics from `Node`, `Packet`, `NeighborInfo`, telemetry, and position updates.
- **FR-013**: The feature shall explicitly integrate with the existing `NeighborInfoHandler` path so neighbor-report topology can be captured during each dwell.
- **FR-014**: The feature shall deduplicate discovered nodes within a preset while still tracking aggregate activity counts.
- **FR-015**: The feature shall retain preset-specific observations even when the same node appears in multiple presets.
- **FR-016**: Distance displays shall use shared Meshtastic formatting utilities and `Node.distance(...)` semantics when both nodes have valid positions.
### Persistence and History
- **FR-017**: Discovery data shall be stored in new Room KMP entities within the active per-device database managed by `DatabaseProvider`.
- **FR-018**: Persisted history shall include enough data to rebuild summary, map, topology, and exported reports without a live radio connection.
- **FR-019**: Deleting a session shall cascade to related preset-result and discovered-node rows.
- **FR-020**: Session history shall load reactively and sort newest-first by session start time.
### Visualization and Summary
- **FR-021**: The feature shall provide at least Overview, Map, and History surfaces.
- **FR-022**: The map view shall use the existing CompositionLocal map abstraction pattern so `google`, `fdroid`, and Desktop can render with target-appropriate providers.
- **FR-023**: The map UI shall support filtering by preset and toggling topology overlays when neighbor info is available.
- **FR-024**: The summary UI shall rank presets using a documented deterministic heuristic even when AI is unavailable.
- **FR-025**: The summary UI shall show incomplete or failed presets without silently dropping them from the historical record.
### AI Recommendation
- **FR-026**: The feature shall define a shared recommendation-engine contract in `commonMain` and platform-specific implementations where needed.
- **FR-027**: On supported Google-flavor Android devices, the app may use Gemini Nano through Google AI Edge SDK for an on-device narrative recommendation.
- **FR-028**: When AI is unavailable for any reason, the UI shall fall back automatically to a deterministic structured recommendation and comparison table.
- **FR-029**: AI usage shall remain opt-in per session or per feature preference and shall not upload session data to a remote service.
### Navigation, Preferences, and Export
- **FR-030**: The feature shall persist lightweight user defaults (last dwell time, last preset selection, whether AI expansion is enabled, last-used filters) through DataStore in `core:prefs`.
- **FR-031**: The feature shall expose a deep link under the Settings route family.
- **FR-032**: Android export shall use the platform share / document flow and should produce a PDF when platform snapshot generation succeeds.
- **FR-033**: Desktop export shall use a local save flow and may omit map imagery if no snapshot pipeline exists.
- **FR-034**: All user-visible strings shall live in `core/resources/src/commonMain/composeResources/values/strings.xml`.
- **FR-035**: The UI shall use `MeshtasticIcons` rather than Material icon constants.
### Hardware Gating
- **FR-036**: The preset picker shall gate 2.4 GHz presets based on connected-radio capability resolved through current radio metadata and `DeviceHardwareRepository` lookups.
- **FR-037**: Unsupported or unknown capability shall block scan start for 2.4 GHz presets.
- **FR-038**: Capability resolution shall tolerate partial hardware data by falling back from model lookup to target lookup where possible.
## Non-Functional Requirements
- **NFR-001**: All business logic, aggregation, session state, and recommendation heuristics shall live in `commonMain`.
- **NFR-002**: Platform-specific code shall be limited to map rendering, Gemini Nano integration, export/share flows, and other thin host integrations.
- **NFR-003**: Coroutine code shall use project conventions such as `safeCatching {}` and injected dispatchers / shared dispatcher utilities rather than ad-hoc exception handling or `Dispatchers.IO`.
- **NFR-004**: The feature shall be compatible with existing BLE reconnect behavior and must not introduce a second reconnect loop.
- **NFR-005**: History loading shall load and render the session list within 500 ms for up to 100 stored sessions with typical Meshtastic node counts, and the list shall scroll at 60 fps once loaded.
- **NFR-006**: Unsupported targets (for example Desktop without a native map snapshotter or Android without Gemini Nano) shall degrade gracefully rather than hiding the whole feature.
- **NFR-007**: The feature shall preserve privacy by keeping recommendations on-device and by avoiding unnecessary export of raw packet payloads in shared reports.
- **NFR-008**: The design shall remain buildable across Android, Desktop/JVM, and iOS source-set compilation expectations even if full host UI is only wired on Android and Desktop initially.
## State Machine
```mermaid
stateDiagram-v2
[*] --> Idle
Idle --> Preparing : Start scan
Preparing --> SwitchingPreset : Snapshot home preset
SwitchingPreset --> WaitingForReconnect : Admin preset change sent
WaitingForReconnect --> Dwelling : Connection stable + preset confirmed
WaitingForReconnect --> Failed : Reconnect timeout / config error
Dwelling --> SwitchingPreset : Dwell complete and more presets remain
Dwelling --> Analyzing : Final dwell complete
Dwelling --> Cancelling : User taps Stop
SwitchingPreset --> Cancelling : User taps Stop
WaitingForReconnect --> Cancelling : User taps Stop
Cancelling --> RestoringHomePreset
Analyzing --> RestoringHomePreset : Completed or partial summary ready
Failed --> RestoringHomePreset : Home preset known
RestoringHomePreset --> Completed : Restore success
RestoringHomePreset --> Completed : Restore warning persisted
Completed --> [*]
```
### State Notes
- **Preparing** gathers current config, validates selected presets, resolves hardware capability, and creates an in-memory session record.
- **SwitchingPreset** is a short-lived command-dispatch state.
- **WaitingForReconnect** reuses global radio connection state and must not count toward dwell time.
- **Dwelling** is the only state in which packet metrics accumulate toward the current preset result.
- **Analyzing** computes aggregates, rankings, summaries, and optional AI input payloads.
- **Completed** covers successful, cancelled-with-partial-results, and failed-but-persisted outcomes; terminal UI messaging differentiates them.
## Data Flow
```mermaid
flowchart TD
A[Discovery Settings UI] --> B[DiscoveryViewModel commonMain]
B --> C[DiscoveryScanCoordinator commonMain]
C --> D[Existing radio config/admin path]
D --> E[Radio / firmware preset change]
E --> F[BleRadioTransport + BleReconnectPolicy]
F --> G[ServiceRepository connection state]
G --> C
H[ServiceRepository.meshPacketFlow] --> I[Existing packet handlers]
I --> J[NodeRepository / NeighborInfoHandler / PacketRepository]
H --> K[DiscoveryPacketCollector]
J --> K
C --> K
K --> L[DiscoveryRepository]
L --> M[Room KMP entities in active DB]
M --> N[History + Summary UI]
M --> O[Map presentation model]
M --> P[Deterministic ranking engine]
P --> Q[Gemini Nano adapter if available]
P --> R[Structured fallback recommendation]
Q --> N
R --> N
```
## Architecture Notes
### Shared feature module
- `feature/discovery/src/commonMain/...` owns:
- session state machine
- scan coordinator
- packet aggregation / ranking logic
- summary presentation models
- route-level screen composables
- Koin module and ViewModels
- `feature/discovery/src/androidMain/...` owns:
- Gemini Nano adapter
- Android share / PDF implementation
- Android map bindings if the discovery screen needs a specialized map provider beyond current `LocalMapViewProvider`
- `feature/discovery/src/jvmMain/...` owns:
- Desktop export implementation
- Desktop map placeholder or provider adapter
### Existing integration points
- **Neighbor info**: `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt`, `core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt`
- **Preset mutation**: `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/LoRaConfigItemList.kt`, `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt`, `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt`
- **BLE reconnect**: `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt`, `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt`
- **Map state**: `feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt`, `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- **Database**: `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt`, `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt`, `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt`, `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt`
- **Navigation**: `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`, `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt`
- **Prefs**: `core:repository` preference interfaces + `core:prefs` DataStore implementations
## Ranking and Recommendation Heuristic
The deterministic fallback recommendation shall be computed before any AI pass so it is always available. The default ranking order is:
1. Highest **unique discovered node count**.
2. Highest **neighbor-report diversity** (unique neighbor mentions / topology richness).
3. Highest **packet count** excluding duplicate self-noise where practical.
4. Best **median link quality** (median SNR first, then RSSI).
5. Greatest **best known distance** to a valid-position node.
6. Lowest **failure / reconnect penalty**.
If two presets still tie after all heuristics, the UI labels them as tied and avoids inventing a false winner.
## Edge Cases and Failure Handling
| Scenario | Expected Behavior |
|---|---|
| Radio disconnects during dwell | Pause the dwell timer, enter `WaitingForReconnect`, resume only when the connection is stable again. |
| Radio never reconnects after preset switch | Mark the preset failed, persist partial data, restore home preset if possible, and end the session with a warning. |
| User leaves the screen while scan runs | The ViewModel / coordinator may continue as long as the app process and radio service remain alive; on process death, partial results already flushed to DB remain available. |
| Home preset cannot be read before start | Block scan start with a clear error; the feature must never mutate presets without a restore target. |
| Duplicate packets or repeated node updates | Aggregate counts carefully and deduplicate discovered-node rows per preset / node. |
| Node has no valid position | Include it in summary and node lists, but exclude it from map-only totals and distance calculations. |
| Neighbor info references unknown nodes | Persist the relationship as numeric node IDs; resolve names opportunistically from `NodeRepository` when available. |
| 2.4 GHz capability lookup is stale or missing | Disable 2.4 GHz presets and explain that hardware capability could not be verified. |
| AI model not installed / not permitted / throws | Keep summary fully functional with deterministic fallback and a non-blocking notice. |
| Map provider unavailable on current target | Show placeholder or list-backed fallback rather than suppressing session access. |
| Export snapshot fails | Share a text/table-only report and explain that map imagery could not be attached. |
| User switches to another radio mid-session | Cancel the active session, save partial results against the original active DB, and require an explicit new start for the newly selected radio. |
## Success Criteria
### Measurable Outcomes
- **SC-001**: A user can complete a single-preset or multi-preset scan without manual radio reconnection steps.
- **SC-002**: Completed sessions can be reopened later and still show useful summary + map data.
- **SC-003**: Unsupported platforms still present a valid non-AI, non-map-crash experience.
- **SC-004**: The scan engine introduces zero new BLE reconnect logic; all reconnection uses `BleReconnectPolicy` exclusively. No parallel packet decoder is introduced — collection flows through `ServiceRepository.meshPacketFlow`.
## Open Implementation Constraints
1. The canonical deep link base is `meshtastic://meshtastic`.
2. Existing settings routing prefers lowercase hyphenated path segments; discovery should follow that convention while optionally accepting a camelCase compatibility alias.
3. Meshtastic-Android keeps separate per-device Room databases, so discovery history is naturally scoped to the currently active radio unless exported.
4. `core/proto` remains read-only; discovery must be implemented from existing packet types.
## References
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/AdminPacketHandlerImpl.kt`
- `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/CommandSenderImpl.kt`
- `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleReconnectPolicy.kt`
- `core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt`
- `feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/BaseMapViewModel.kt`
- `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/MapViewProvider.kt`
- `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt`
- `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt`

View File

@@ -0,0 +1,185 @@
# Tasks — Local Mesh Discovery
## Legend
- `[ ]` not started
- `[P]` can be done in parallel with other tasks in the same phase once dependencies are met
- Task IDs are ordered for dependency tracking, not necessarily for one-commit-per-task execution
## Phase 0 — Design Standards Gate (Blocking)
- [ ] **D000** `[UI-GATE]` Review `.skills/design-standards/SKILL.md` and upstream Meshtastic design standards; record constraints for discovery scan screen, map overlays, summary cards, session history list, and AI recommendation UI.
**Phase dependency**: none
**Exit criteria**: Design constraints are documented and ready to guide implementation.
## Phase 1 — Setup (module creation, navigation routes, DI)
- [ ] **D001** Create `feature/discovery/` with `meshtastic.kmp.feature` + serialization plugin setup, source sets, namespace, and baseline dependencies.
- [ ] **D002** Add `FeatureDiscoveryModule` with `@Module` + `@ComponentScan("org.meshtastic.feature.discovery")`.
- [ ] **D003** Register the module in `settings.gradle.kts` and include it in Android / Desktop Koin roots.
- [ ] **D004** Add typed discovery routes to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`.
- [ ] **D005** Extend `DeepLinkRouter` and navigation tests for discovery entry paths.
- [ ] **D006** Add the Settings > Advanced entry point and placeholder discovery screen wiring.
**Phase dependency**: none
**Exit criteria**: the app can navigate to an empty/placeholder Local Mesh Discovery screen and compile across KMP targets.
## Phase 2 — Data model (Room entities, DAOs, migrations)
- [ ] **D007** [P] Add `DiscoverySessionEntity`, `DiscoveryPresetResultEntity`, and `DiscoveredNodeEntity` under `core:database`.
- [ ] **D008** [P] Add discovery DAO interfaces and relation models.
- [ ] **D009** Register entities / DAOs in `MeshtasticDatabase` and bump the schema version.
- [ ] **D010** Add DAO tests for insert, relation loading, sort order, and cascade deletion.
- [ ] **D011** Add migration coverage for the new schema version.
**Depends on**: D001
**Exit criteria**: discovery data can be persisted and queried in tests.
## Phase 3 — Scan engine (preset cycling, admin messages, BLE reconnection)
- [ ] **D012** [P] Add discovery prefs contract in `core:repository` and DataStore implementation in `core:prefs`.
- [ ] **D013** [P] Implement `DiscoveryScanState` / state machine in `commonMain`.
- [ ] **D014** [P] Implement `DiscoveryScanCoordinator` to validate inputs, snapshot home preset, switch presets, and manage dwell timing.
- [ ] **D014b** [P] Implement `DiscoveryViewModel` in `commonMain` to expose scan state, session data, and user actions to the UI layer. Wire to `DiscoveryScanCoordinator` and `DiscoveryRepository`.
- [ ] **D015** [P] Reuse the existing radio config/admin path to apply `Config.LoRaConfig` preset changes.
- [ ] **D016** [P] Observe shared connection state and pause/resume around BLE reconnects without introducing a custom reconnect loop.
- [ ] **D017** [P] Persist scan lifecycle milestones (session start, preset start, stop/cancel/fail, restore result).
- [ ] **D018** Add unit tests for normal flow, reconnect delays, timeout, cancel, and home-preset restore failure.
**Depends on**: D007-D009
**Exit criteria**: a scan can run end-to-end against fake or mocked dependencies and persist lifecycle state correctly.
## Phase 4 — Packet collection (integrate with existing packet pipeline)
- [ ] **D019** [P] Implement `DiscoveryPacketCollector` that listens to shared packet / node / neighbor flows.
- [ ] **D020** [P] Trigger neighbor info requests at dwell boundaries through the existing command path.
- [ ] **D021** [P] Aggregate per-preset metrics (packet count, telemetry count, neighbor count, unique nodes, best distance, link quality).
- [ ] **D022** [P] Upsert `DiscoveredNodeEntity` rows with deduped per-preset observations.
- [ ] **D023** Add tests for duplicate packets, nodes without positions, and neighbor-info-only sightings.
**Depends on**: D014-D017
**Exit criteria**: preset results and per-node observations are populated from live/shared data sources.
## Phase 5 — Map visualization (CompositionLocal map, markers, topology)
- [ ] **D024** [P] Build shared discovery map presentation models and preset filter state in `commonMain`.
- [ ] **D025** [P] Implement `DiscoveryMapScreen` and node detail sheet/cards using Compose Multiplatform. Verify that distance displays use `MetricFormatter` / `Node.distance(...)` shared formatting (FR-016).
- [ ] **D026** [P] Reuse or extend platform map providers for discovery overlays on Android.
- [ ] **D027** [P] Provide Desktop map fallback (provider or placeholder/list hybrid) that does not break the feature.
- [ ] **D028** Add UI tests for preset filtering, mapped/unmapped counts, and topology toggle behavior.
**Depends on**: D019-D022
**Exit criteria**: persisted discovery sessions can render a map tab or safe fallback on supported targets.
## Phase 6 — Summary / analysis (per-preset metrics, charts)
- [ ] **D029** [P] Implement `DiscoveryRankingEngine` deterministic heuristic in `commonMain`.
- [ ] **D030** [P] Build summary presentation models for overview cards, comparison table, and tie explanations.
- [ ] **D031** [P] Implement `DiscoverySummaryScreen` with per-preset ranking, warnings, and partial-session handling.
- [ ] **D032** Add tests for ranking ties, failed presets, and deterministic fallback output.
**Depends on**: D021-D022
**Exit criteria**: every completed or partial session produces a usable non-AI summary.
## Phase 7 — AI recommendation (Gemini Nano integration)
- [ ] **D033** [P] Define `DiscoveryRecommendationEngine` and result contracts in `commonMain`.
- [ ] **D034** [P] Bind `RuleBasedDiscoveryRecommendationEngine` as the always-available default.
- [ ] **D035** [P] Implement Android Google-flavor Gemini Nano adapter and availability checks.
- [ ] **D036** [P] Add opt-in UI and non-blocking fallback behavior.
- [ ] **D037** Add tests for supported / unsupported / failure cases.
**Depends on**: D029-D031
**Exit criteria**: AI can enhance the summary on supported devices without blocking unsupported targets.
## Phase 8 — Session history (list, detail, delete)
- [ ] **D038** [P] Implement `DiscoveryHistoryScreen` with newest-first sessions and status chips.
- [ ] **D039** [P] Implement session detail routing and history-to-detail navigation.
- [ ] **D040** [P] Implement delete flow with cascade validation.
- [ ] **D041** Ensure historical sessions load entirely from Room without requiring a live radio connection.
- [ ] **D042** Add tests for history sorting, deep-link session load, and delete behavior.
**Depends on**: D007-D010, D029-D031
**Exit criteria**: stored sessions can be reopened and managed after app restart.
## Phase 9 — Polish (PDF export, accessibility, edge cases)
- [ ] **D043** [P] Implement Android share / PDF export and Desktop save/export fallback.
- [ ] **D044** [P] Add accessibility polish: semantics, progress announcements, disabled-preset explanations, and large-screen layout checks.
- [ ] **D045** [P] Finalize 2.4 GHz hardware gating using `DeviceHardwareRepository` + current radio metadata.
- [ ] **D046** [P] Add logging / diagnostics and make sure the feature is debuggable through existing app logging tools.
- [ ] **D047** [P] Add strings, icons, and docs updates (`core/resources`, deep-link docs, quickstart references).
- [ ] **D048** Run targeted and full verification commands.
**Depends on**: all previous phases
**Exit criteria**: feature is shippable, documented, accessible, and validated.
## Dependency Graph
```mermaid
flowchart TD
P1[Phase 1 Setup] --> P2[Phase 2 Data model]
P2 --> P3[Phase 3 Scan engine]
P3 --> P4[Phase 4 Packet collection]
P4 --> P5[Phase 5 Map visualization]
P4 --> P6[Phase 6 Summary and analysis]
P6 --> P7[Phase 7 AI recommendation]
P2 --> P8[Phase 8 Session history]
P6 --> P8
P5 --> P9[Phase 9 Polish]
P7 --> P9
P8 --> P9
```
## Parallelization Opportunities
### After Phase 1
- `[P]` D007-D008 (data model) can proceed while D006 finishes the initial placeholder UI.
- `[P]` Deep-link tests and settings entry UI work can be split between navigation and UI contributors.
### After Phase 2
- `[P]` D012 (prefs) can run in parallel with D013-D014 (scan state machine).
- `[P]` DAO tests and migration tests can be split because they touch different `core:database` test suites.
### After Phase 4
- `[P]` Phase 5 map work and Phase 6 summary work can proceed in parallel because both depend on persisted discovery aggregates rather than each other.
- `[P]` Android-specific map bindings and Desktop fallback work can be split by target owner.
### After Phase 6
- `[P]` Phase 7 AI integration can proceed independently from Phase 8 history UI once the summary contract is stable.
- `[P]` Export work in Phase 9 can start early once summary/detail presentation models are frozen.
## Suggested Validation Commands
### Targeted during development
```bash
./gradlew :feature:discovery:allTests
./gradlew :core:database:allTests
./gradlew :app:testFdroidDebugUnitTest
./gradlew kmpSmokeCompile
```
### Final local verification
```bash
./gradlew spotlessApply detekt assembleDebug test allTests
./gradlew lintFdroidDebug lintGoogleDebug
```
## Suggested Commit / PR Slices
1. **Navigation + module skeleton**
2. **Room schema + DAO tests**
3. **Scan engine + persistence**
4. **Summary + history UI**
5. **Map overlays + export**
6. **Optional Gemini Nano integration**
Keeping AI as the last slice reduces risk and makes it easy to land the core diagnostic feature even if device/model support needs extra iteration.

View File

@@ -0,0 +1,67 @@
# Requirements Quality Checklist — Node List Layout
Use this checklist to review the specification before implementation starts.
## Scope and User Value
- [ ] The spec clearly describes the user problem (large meshes require denser node lists).
- [ ] The feature scope is limited to layout density switching, compact toggles, adaptive sizing, and help documentation.
- [ ] Out-of-scope items are implicitly bounded (no new data model, no filter changes, no navigation changes).
## User Stories
- [ ] Four user stories are present and prioritized P1P3.
- [ ] Each user story is independently testable.
- [ ] Each user story explains why the priority matters.
- [ ] Acceptance scenarios cover success and edge cases.
## Architecture Fit
- [ ] The spec uses Compose Multiplatform + Material 3 terminology.
- [ ] The spec uses Navigation 3 patterns where applicable.
- [ ] All business logic and UI reside in `commonMain`.
- [ ] No platform-specific code is required for this feature.
- [ ] The feature modifies existing modules (`feature/node`, `feature/settings`, `core/prefs`) rather than creating unnecessary new modules.
## Preferences and Persistence
- [ ] All 10 DataStore keys are documented with names, types, and defaults.
- [ ] The density enum is persisted as a string (enum name) with a safe fallback.
- [ ] Toggle defaults are specified (all `true` except `lastHeardIsRelative`).
- [ ] Preference key naming uses an enum or constant to prevent string drift.
## Layout Requirements
- [ ] The compact layout structure (2-column, 3-row) is clearly documented.
- [ ] The adaptive circle sizing formula is specified with min/max bounds.
- [ ] All 9 compact toggles are listed with their data conditions.
- [ ] The "Relative Last Heard Time" disabled state dependency is documented.
- [ ] Row rendering order matches the toggle order in settings.
- [ ] Complete layout is documented as unconditional (no toggles).
## Edge Cases
- [ ] All compact toggles disabled → only name row + minimum circle.
- [ ] Missing data for toggled-on field → field absent, no placeholder.
- [ ] Signal/Hops mutual exclusivity → documented and tested.
- [ ] Channel 0 → hidden regardless of toggle.
- [ ] Connected node → distance excluded.
- [ ] MQTT nodes → signal excluded.
- [ ] Future dates → last heard hidden (> 1 year guard).
## Accessibility
- [ ] TalkBack semantics are required for both layouts (FR-025).
- [ ] Content descriptions cover all visible fields.
## Resource Conventions
- [ ] String resources planned for `core/resources/.../values/strings.xml`.
- [ ] Icons reference `MeshtasticIcons`.
- [ ] All framework references are specific to the Meshtastic-Android KMP stack.
## Testing and Delivery
- [ ] Unit tests for preference defaults and `lineCount` calculation.
- [ ] Compose UI tests for both layout variants.
- [ ] Full verification command documented: `./gradlew spotlessApply detekt assembleDebug test allTests`.

View File

@@ -0,0 +1,124 @@
# Data Model — Node List Layout
## Overview
The Node List Layout feature does not introduce new database entities. It adds **preference keys** to DataStore and a **density enum** that controls how existing `Node` model data is rendered. The data model is entirely read-only from the layout perspective — node data comes from the existing Room KMP pipeline.
## Entity Relationship
```mermaid
erDiagram
DataStore ||--o{ NodeListLayoutPreferences : stores
NodeListLayoutPreferences ||--|| NodeListDensity : selects
NodeListDensity ||--|| NodeItem : "renders (COMPLETE)"
NodeListDensity ||--|| NodeItemCompact : "renders (COMPACT)"
NodeItemCompact ||--o{ CompactToggle : "visibility driven by"
NodeItem }o--|| Node : reads
NodeItemCompact }o--|| Node : reads
Node ||--|| NodeEntity : "backed by"
```
## Density Enum
```kotlin
package org.meshtastic.feature.node.model
enum class NodeListDensity {
COMPLETE,
COMPACT;
}
```
- Persisted as a `String` (enum name) in DataStore under key `nodeListDensity`.
- Default: `COMPLETE`.
## Preference Keys
```kotlin
package org.meshtastic.core.prefs.ui
enum class NodeListLayoutPreferences(val key: String, val defaultValue: Boolean) {
SHOW_POWER("shouldShowPower", true),
SHOW_LAST_HEARD("shouldShowLastHeard", true),
LAST_HEARD_RELATIVE("lastHeardIsRelative", false),
SHOW_LOCATION("shouldShowLocation", true),
SHOW_HOPS("shouldShowHops", true),
SHOW_SIGNAL("shouldShowSignal", true),
SHOW_CHANNEL("shouldShowChannel", true),
SHOW_ROLE("shouldShowRole", true),
SHOW_TELEMETRY("shouldShowTelemetry", true);
}
```
### Preference Access Pattern
Each key is exposed as a `StateFlow<Boolean>` in `UiPrefsImpl`:
```kotlin
val shouldShowPower: StateFlow<Boolean> = dataStore.data
.map { it[booleanPreferencesKey("shouldShowPower")] ?: true }
.stateIn(scope, SharingStarted.Eagerly, true)
```
The density preference follows the same pattern but maps to the enum:
```kotlin
val nodeListDensity: StateFlow<NodeListDensity> = dataStore.data
.map { prefs ->
val name = prefs[stringPreferencesKey("nodeListDensity")] ?: "COMPLETE"
NodeListDensity.valueOf(name)
}
.stateIn(scope, SharingStarted.Eagerly, NodeListDensity.COMPLETE)
```
## Node Data Fields Used by Layout
The layout reads from the existing `Node` model in `core:model`. No new fields are added.
| Field | Type | Used By | Condition |
|-------|------|---------|-----------|
| `longName` | `String?` | Both | Always shown |
| `shortName` | `String?` | Both | Circle avatar |
| `lastHeard` | `Long` | Both | Non-zero, not > 1 year future |
| `hopsAway` | `Int` | Both | `> 0` for hop count, `== 0` for signal |
| `snr` | `Float` | Both | `!= 0` and `!viaMqtt` |
| `rssi` | `Int` | Complete | Gradient gauge |
| `batteryLevel` | `Int?` | Both | Non-null |
| `channel` | `Int` | Both | `> 0` |
| `position` | `Position?` | Both | Non-null, valid lat/lon |
| `role` | `DeviceRole` | Both | Always (defaults to 0) |
| `viaMqtt` | `Boolean` | Both | Signal exclusion gate |
| `isFavorite` | `Boolean` | Both | Star icon |
| `hasPositionLog` | `Boolean` | Both | Log icon visibility |
| `hasEnvironmentLog` | `Boolean` | Both | Log icon visibility |
| `hasDetectionSensorLog` | `Boolean` | Both | Log icon visibility |
| `hasTracerouteLog` | `Boolean` | Both | Log icon visibility |
| `hasDeviceMetricsLog` | `Boolean` | Both | Log icon visibility |
## Adaptive Circle Sizing
The compact circle size is derived from a `lineCount` property:
```kotlin
val lineCount: Int = buildList {
add(1) // Row 1: name — always present
if (shouldShowLastHeard) add(1)
if (shouldShowLocation || shouldShowHops || shouldShowSignal ||
shouldShowChannel || shouldShowRole || shouldShowTelemetry) add(1)
}.size
val circleSize: Dp = max(36.dp, min(70.dp, 24.dp * lineCount))
```
| lineCount | Circle Size | Active Rows |
|-----------|-------------|-------------|
| 1 | 36.dp | Name only |
| 2 | 48.dp | Name + last heard OR Name + combined |
| 3 | 70.dp | Name + last heard + combined |
## Validation Rules
- `nodeListDensity` must be a valid `NodeListDensity` enum name. Invalid values fall back to `COMPLETE`.
- `lastHeardIsRelative` is functionally irrelevant when `shouldShowLastHeard` is `false` (the UI disables the toggle).
- All boolean preferences default to `true` except `lastHeardIsRelative` which defaults to `false`.
- The layout never writes to `Node` data — all mutations flow through the existing packet processing pipeline.

View File

@@ -0,0 +1,99 @@
# Implementation Plan — Node List Layout
## Overview
Add a density-switching system to the existing node list in `feature/node/`. Users choose between a **Complete** layout (all fields shown) and a **Compact** layout (user-configurable field toggles with adaptive sizing). The feature is entirely `commonMain` — no platform-specific code required.
## Technical Context
| Area | Choice |
|---|---|
| Language | Kotlin 2.3+ |
| UI | Compose Multiplatform + Material 3 Adaptive |
| Navigation | JetBrains Navigation 3 typed `NavKey` routes |
| DI | Koin 4.2+ K2 compiler plugin |
| Persistence | DataStore via `core:prefs` for toggle/density state |
| Data source | Room KMP via `core:database` (read-only from layout perspective) |
| Build | Gradle Kotlin DSL + convention plugins in `build-logic/` |
## Module Impact
This feature modifies existing modules rather than creating a new one:
```text
feature/node/ ← Primary changes
├── src/commonMain/kotlin/org/meshtastic/feature/node/
│ ├── component/
│ │ ├── NodeItem.kt ← Existing — refactor to "Complete" role
│ │ ├── NodeItemCompact.kt ← NEW — compact row composable
│ │ └── NodeListHelp.kt ← NEW — help bottom sheet
│ ├── list/
│ │ ├── NodeListScreen.kt ← Modify — density-aware delegation
│ │ ├── NodeListViewModel.kt ← Modify — expose density + toggle state
│ │ └── NodeFilterPreferences.kt ← Modify — add layout preferences
│ └── model/
│ └── NodeListDensity.kt ← NEW — enum COMPLETE / COMPACT
core/prefs/ ← Preference keys
├── src/commonMain/kotlin/org/meshtastic/core/prefs/
│ └── ui/UiPrefsImpl.kt ← Modify — add layout DataStore keys
feature/settings/ ← Settings UI
├── src/commonMain/kotlin/org/meshtastic/feature/settings/
│ └── app/
│ └── NodeLayoutSettings.kt ← NEW — settings section composable
core/ui/ ← Shared components (if any new)
├── src/commonMain/kotlin/org/meshtastic/core/ui/component/
│ └── LoRaSignalStrengthMeter.kt ← NEW if not already present
```
## Integration Points
### Preference Keys
Add to `core:prefs` DataStore:
| Key | Type | Default | Purpose |
|-----|------|---------|---------|
| `nodeListDensity` | `String` (enum name) | `"COMPLETE"` | Active density mode |
| `shouldShowPower` | `Boolean` | `true` | Compact: battery visibility |
| `shouldShowLastHeard` | `Boolean` | `true` | Compact: last heard row |
| `lastHeardIsRelative` | `Boolean` | `false` | Compact: relative vs absolute time |
| `shouldShowLocation` | `Boolean` | `true` | Compact: distance + bearing |
| `shouldShowHops` | `Boolean` | `true` | Compact: hop count |
| `shouldShowSignal` | `Boolean` | `true` | Compact: signal strength |
| `shouldShowChannel` | `Boolean` | `true` | Compact: channel number |
| `shouldShowRole` | `Boolean` | `true` | Compact: device role icons |
| `shouldShowTelemetry` | `Boolean` | `true` | Compact: log/telemetry icons |
### Navigation
No new routes needed. The settings section is embedded within the existing `SettingsRoute.AppSettings` screen. The help sheet is a `ModalBottomSheet` triggered from `NodeListScreen`.
### Data Flow
1. User selects density in Settings → DataStore write
2. `NodeListViewModel` collects density + toggle StateFlows
3. `NodeListScreen` delegates to `NodeItem` or `NodeItemCompact` based on density
4. `NodeItemCompact` reads toggle state to conditionally render rows
5. `lineCount` derived from toggles drives adaptive circle sizing
## Design Constraints
- All UI lives in `commonMain` — layout density is not platform-specific
- Existing `NodeItem` composable is refactored to serve as the Complete layout with no toggle logic
- `NodeItemCompact` is a new composable, not a modified version of `NodeItem`
- The live preview in Settings reuses the same composables with a sample node from the database
- All strings added to `core/resources/src/commonMain/composeResources/values/strings.xml`
- Icons use `MeshtasticIcons` exclusively
- `getSnrColor` function must be accessible from both layout variants
## Risk Assessment
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Scroll performance degrades with 200+ compact rows | Low | Use stable `key` in `LazyColumn`, avoid unnecessary recompositions via `derivedStateOf` |
| Toggle state out of sync between Settings and NodeList | Low | Both screens observe the same DataStore flows |
| Existing `NodeItem` refactor breaks current behavior | Medium | Add snapshot/screenshot tests for Complete layout before modifying |
| Live preview renders incorrectly without database nodes | Low | Show placeholder text when no nodes exist |

View File

@@ -0,0 +1,90 @@
# Quickstart — Node List Layout
## Purpose
This guide helps a Meshtastic-Android contributor bootstrap, navigate, test, and debug the Node List Layout feature.
## Prerequisites
- **JDK 21**
- **Android SDK** installed and `ANDROID_HOME` available to Gradle
- **Git submodule initialized** for `core/proto`
- A working `local.properties` file (copy from `secrets.defaults.properties` if needed)
## Workspace Bootstrap
```bash
git submodule update --init
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## Feature Access Path
Once implemented, the feature is accessible at:
- **Settings UI**: Settings > App Settings > Node Layout
- **Node List**: The Nodes tab renders rows based on the selected density
No new navigation routes or deep links are required.
## Key Files
| File | Purpose |
|------|---------|
| `feature/node/src/commonMain/.../model/NodeListDensity.kt` | `COMPLETE` / `COMPACT` enum |
| `feature/node/src/commonMain/.../component/NodeItem.kt` | Complete row composable (existing, refactored) |
| `feature/node/src/commonMain/.../component/NodeItemCompact.kt` | Compact row composable (new) |
| `feature/node/src/commonMain/.../component/NodeListHelp.kt` | Help bottom sheet (new) |
| `feature/node/src/commonMain/.../list/NodeListScreen.kt` | Density-aware list delegation |
| `feature/node/src/commonMain/.../list/NodeListViewModel.kt` | Exposes density + toggle state |
| `feature/node/src/commonMain/.../list/NodeFilterPreferences.kt` | Layout preference integration |
| `core/prefs/src/commonMain/.../ui/UiPrefsImpl.kt` | DataStore preference keys |
| `feature/settings/src/commonMain/.../app/NodeLayoutSettings.kt` | Settings section UI (new) |
| `core/resources/src/commonMain/composeResources/values/strings.xml` | Toggle labels, help text |
## Test Commands
### Run feature module tests (KMP)
```bash
./gradlew :feature:node:allTests
./gradlew :feature:settings:allTests
```
### Run core prefs tests
```bash
./gradlew :core:prefs:allTests
```
### Full verification
```bash
./gradlew spotlessApply detekt assembleDebug test allTests
```
### Compile-only check (fast)
```bash
./gradlew :feature:node:compileKotlinJvm :feature:settings:compileKotlinJvm
```
## Development Workflow
1. **Start with preferences** — add DataStore keys in `UiPrefsImpl.kt` and verify defaults with a unit test.
2. **Build the compact composable** — create `NodeItemCompact.kt` with hardcoded toggles first, then wire to DataStore.
3. **Wire the list** — modify `NodeListScreen.kt` to switch between `NodeItem` and `NodeItemCompact`.
4. **Build settings UI** — create `NodeLayoutSettings.kt` with the picker, toggles, and live preview.
5. **Add the help sheet** — create `NodeListHelp.kt` with signal strength documentation.
6. **Test** — run `allTests` for both `feature:node` and `feature:settings`.
## Debugging Tips
- **Toggle not persisting**: Check that the DataStore key string matches `NodeListLayoutPreferences` enum value exactly.
- **Circle size wrong**: Verify `lineCount` derivation — it counts *toggle state*, not *data presence*.
- **Live preview empty**: The preview requires at least one node in the Room database. Connect to a radio or use test fixtures.
- **LazyColumn jank**: Ensure stable `key` parameters are set on `LazyColumn` items. Profile with Layout Inspector if needed.
## Logging
This feature does not require custom logging beyond standard Compose recomposition debugging. Use Android Studio Layout Inspector to diagnose rendering issues.

View File

@@ -0,0 +1,121 @@
# Research — Node List Layout
## R-001: Preference storage strategy for layout toggles
### Decision
Store all layout preferences in DataStore via `core:prefs`, using the existing `UiPrefsImpl` pattern. Each toggle is a `Boolean` preference exposed as a `StateFlow`.
### Rationale
- The codebase already uses DataStore for similar UI preferences (BLE scan prefs, node filter options, sort order).
- DataStore flows integrate naturally with Compose `collectAsState()` for zero-delay UI updates.
- Using a single DataStore instance per preference scope avoids database overhead for simple toggle state.
### Alternatives considered
- **Room table for layout preferences**: Rejected — too heavy for 10 boolean keys. Room is appropriate for entity data, not UI presentation state.
- **SharedPreferences wrapper**: Rejected — DataStore is the project standard and provides Flow-based observation.
- **In-memory-only state**: Rejected — preferences must persist across app restarts (FR-002, FR-004).
### Consequences
- All 10 preference keys must be defined as constants to prevent key string drift (NFR-003).
- Eagerly-seeded `StateFlow` with defaults ensures the UI never flickers during DataStore cold reads.
---
## R-002: Compact row composable architecture
### Decision
Create `NodeItemCompact` as a standalone composable, separate from the existing `NodeItem`. Do not make `NodeItem` configurable — keep it as the unconditional Complete layout.
### Rationale
- The Complete and Compact layouts have fundamentally different structures (fixed rows vs toggle-driven rows, static circle vs adaptive circle).
- Sharing a single composable with toggle parameters would create a complex conditional tree that is harder to maintain and test.
- Two separate composables allow independent optimization (e.g., Compact can skip measuring rows that are hidden).
### Alternatives considered
- **Single `NodeItem` with a `compact: Boolean` parameter**: Rejected — leads to deeply nested conditionals and makes each layout harder to reason about independently.
- **Compose `AnimatedContent` switching between layouts**: Considered for the transition, but the actual row rendering should be separate composables. `AnimatedContent` or `Crossfade` can wrap the delegation in `NodeListScreen`.
### Consequences
- Both `NodeItem` and `NodeItemCompact` must independently implement TalkBack semantics.
- Shared sub-components (`CircleText`, `MaterialBatteryInfo`, `LastHeardInfo`, etc.) remain in `core:ui` and are composed by both layouts.
---
## R-003: Adaptive circle sizing formula
### Decision
Use `max(36.dp, min(70.dp, 24.dp × lineCount))` where `lineCount` is the number of active row groups (13).
### Rationale
- The formula produces three discrete sizes: 36.dp (1 row), 48.dp (2 rows), 70.dp (3 rows = same as Complete).
- The minimum 36.dp ensures the short name remains readable even when all optional rows are hidden.
- The maximum 70.dp matches the Complete layout's fixed circle, maintaining visual consistency when all rows are enabled.
- 24.dp per line provides proportional vertical alignment between the circle and the content column.
### Alternatives considered
- **Fixed circle size regardless of toggles**: Rejected — wastes vertical space when optional rows are hidden, reducing the density benefit.
- **Continuous scaling based on total row height**: Rejected — overly complex and produces non-standard sizes that feel inconsistent.
- **Collapsing to a capsule/pill shape at minimum**: Rejected — the spec requires the circle to always render as a circle (FR-010).
### Consequences
- The `lineCount` computation must be derived from toggle state, not from actual data presence. This ensures consistent sizing across all nodes regardless of their individual data completeness.
---
## R-004: Signal strength display differences between layouts
### Decision
Complete layout uses `LoRaSignalStrengthMeter` (gradient gauge). Compact layout uses a single colored icon via `getSnrColor()`.
### Rationale
- The gradient gauge provides detailed visual feedback but requires horizontal space that the compact layout cannot afford.
- A single colored icon conveys the essential information (good/fair/bad/very bad) at compact density.
- The same `getSnrColor(snr, preset)` function drives both representations, ensuring consistent thresholds.
### Alternatives considered
- **Same gauge in both layouts**: Rejected — the gauge is too wide for the compact combined-icons row.
- **No signal indicator in compact**: Rejected — signal quality is one of the most useful at-a-glance metrics.
- **Numeric SNR value in compact**: Rejected — requires domain knowledge to interpret; color-coding is more accessible.
### Consequences
- The help sheet must document both representations so users understand the relationship between the compact icon colors and the Complete gauge.
---
## R-005: Settings section integration
### Decision
Add the Node Layout section as part of the existing App Settings screen in `feature/settings`, not as a separate navigation destination.
### Rationale
- Node layout preferences are app-level display settings, not device configuration. They belong alongside other app preferences.
- The existing settings screen already hosts similar UI preference sections.
- No new navigation route is required, keeping the route table compact.
### Alternatives considered
- **Dedicated settings sub-screen**: Rejected — the content (1 picker + 9 toggles + 1 preview) fits within a single scrollable section without needing its own route.
- **Inline controls on the node list itself**: Rejected — pollutes the node list UI and creates discoverability issues.
### Consequences
- The live preview must be lightweight enough to render inline within the settings scroll without janking.
- If the settings screen grows too long in the future, the Node Layout section can be extracted to a sub-screen without changing the preference model.

View File

@@ -0,0 +1,270 @@
# Feature Specification: Node List Layout
**Feature Branch**: `002-node-list-layout`
**Created**: 2026-05-07
**Status**: Not Started
**Input**: Node layout engine with complete and compact views for Compose Multiplatform
## Summary
Node List Layout introduces a density-switching system for the Meshtastic node list, giving users a choice between a full-detail "Complete" layout and a condensed "Compact" layout with per-field toggle controls. The feature lives entirely in `commonMain` (Compose Multiplatform) and persists preferences via DataStore.
## Goals
1. Let users reduce visual noise and scrolling on large meshes (100+ nodes) by switching to a compact row layout.
2. Give users fine-grained control over which data fields appear in compact mode via individually toggleable switches.
3. Provide adaptive circle sizing so the compact layout scales gracefully as fields are enabled or disabled.
4. Document signal strength color semantics in a discoverable help sheet.
## Non-Goals
- Modifying the Complete layout to support per-field toggles (it intentionally shows everything).
- Adding new data fields or metrics beyond what the node model already provides.
- Platform-specific layout variations — all UI is shared `commonMain`.
## User Scenarios & Testing *(mandatory)*
### User Story 1 — Switch Between Complete and Compact Density (Priority: P1)
A Meshtastic user with a large mesh (100+ nodes) wants a denser node list to reduce scrolling. They navigate to Settings > App Settings > Node Layout, switch from "Complete" to "Compact" using a segmented button, and the node list immediately re-renders with smaller rows. Switching back to "Complete" restores the full-detail layout.
**Why this priority**: The density switch is the core mechanic — all compact-specific toggles depend on it existing.
**Independent Test**: Open Settings > Node Layout, toggle between Complete and Compact, navigate to the Nodes tab, and verify the list renders with the correct row style.
**Acceptance Scenarios**:
1. **Given** the user has "Complete" selected, **When** they view the node list, **Then** each row displays the full-detail `NodeItem` layout with all available data fields.
2. **Given** the user switches to "Compact," **When** the node list re-renders, **Then** each row uses `NodeItemCompact` with a condensed two-column layout.
3. **Given** the user switches density, **When** the segmented button animates, **Then** the transition is smooth and a live preview node in Settings updates immediately.
4. **Given** the app is relaunched, **When** the node list loads, **Then** the previously selected density is restored from DataStore preferences.
---
### User Story 2 — Configure Compact Layout Fields (Priority: P1)
A user in Compact mode wants to hide fields they don't care about (e.g., telemetry log icons) and keep only what matters (e.g., last heard time and signal). They open Settings > Node Layout, see toggles for each compact field ordered to match the visual layout, and flip individual switches. The live preview node at the bottom of the settings section updates in real time.
**Why this priority**: Per-field toggles are the primary value proposition of the compact layout — without them, compact is just a fixed alternative.
**Independent Test**: Switch to Compact, disable all toggles one by one, verify each field disappears from both the preview and the node list. Re-enable them and verify they reappear.
**Acceptance Scenarios**:
1. **Given** the user is in Compact mode, **When** they toggle "Power" off, **Then** the battery indicator below the circle disappears.
2. **Given** "Last Heard Time" is toggled off, **When** the node list renders, **Then** the last-heard row (online/offline icon + timestamp) is hidden and the circle shrinks.
3. **Given** "Distance and Bearing" is toggled off, **When** a node has position data, **Then** the distance text and compass arrow are hidden from the combined row.
4. **Given** "Hops Away" is toggled off, **When** a node has `hopsAway > 0`, **Then** the hop count indicator is hidden.
5. **Given** "Signal (Direct Only)" is toggled off, **When** a direct node has SNR data, **Then** the color-coded signal indicator is hidden.
6. **Given** "Channel" is toggled off, **When** a node is on channel > 0, **Then** the channel number indicator is hidden.
7. **Given** "Device Role" is toggled off, **When** the node list renders, **Then** the role icon, unmessagable icon, store-and-forward icon, and MQTT icon are all hidden.
8. **Given** "Log Icons" is toggled off, **When** a node has telemetry logs, **Then** the device metrics, positions, environment, sensor, and trace route icons are hidden.
9. **Given** "Relative Last Heard Time" is toggled on and "Last Heard Time" is on, **When** the row renders, **Then** the timestamp shows relative format (e.g., "2 hours ago") instead of absolute date/time.
10. **Given** "Last Heard Time" is toggled off, **When** the user views the "Relative Last Heard Time" toggle, **Then** it is disabled (grayed out).
---
### User Story 3 — Adaptive Circle Sizing (Priority: P2)
As the user disables rows in the compact view, the short-name circle should shrink proportionally so that single-row nodes don't have an oversized avatar. The circle always renders as a `CircleText` composable — never collapsing to a flat capsule.
**Why this priority**: Visual consistency and density optimization. Without adaptive sizing, disabling rows wastes vertical space.
**Independent Test**: Disable all optional rows (last heard + combined row), verify the circle shrinks to minimum size (36.dp). Enable all rows, verify it grows to maximum (70.dp).
**Acceptance Scenarios**:
1. **Given** all toggleable rows are enabled (`lineCount == 3`), **When** the compact row renders, **Then** the circle diameter is 70.dp.
2. **Given** only the name row is active (`lineCount == 1`), **When** the compact row renders, **Then** the circle diameter is 36.dp (minimum).
3. **Given** any toggle configuration, **When** the compact row renders, **Then** the short name always displays as a `CircleText`, never as a capsule or pill shape.
---
### User Story 4 — Signal Strength Help Documentation (Priority: P3)
A user sees colored signal indicators on their node list and wants to understand what the colors mean. They tap the help button (?) at the bottom of the node list and see a documented legend with color-coded signal entries and a description of the gradient gauge bar.
**Why this priority**: Discoverability — without documentation the color scheme is opaque to new users.
**Independent Test**: Open the node list, tap the help icon, scroll to the "Node Details" section, verify all four signal color entries (Good/Fair/Bad/Very Bad) and the gradient meter entry are present with correct colors and descriptions.
**Acceptance Scenarios**:
1. **Given** the user opens Node List Help, **When** they scroll to "Node Details," **Then** they see four signal strength entries with green, yellow, orange, and red signal icons using MeshtasticIcons.
2. **Given** the user reads "Signal: Good," **Then** the subtitle explains SNR is above the modem preset limit.
3. **Given** the user reads "Signal Strength Meter," **Then** the entry shows a mini gradient gauge and explains it combines SNR and RSSI relative to the modem preset (Complete layout only).
---
### Edge Cases
- **All compact toggles disabled**: Only the name row (long name, lock icon, favorite star) and the circle remain. The circle shrinks to 36.dp. Battery is hidden.
- **Node has no data for a toggled-on field**: The field is simply absent — no placeholder or empty state. For example, "Distance and Bearing" enabled but node has no position → nothing shown.
- **Signal vs. Hops mutual exclusivity**: Signal (Direct Only) only renders when `hopsAway == 0` (direct connection). Hops Away only renders when `hopsAway > 0`. They never appear simultaneously for the same node.
- **Channel 0**: Channel indicator is hidden when `channel == 0` regardless of toggle state, since channel 0 is the default primary channel.
- **Connected node excluded from distance**: Distance and bearing are never shown for the directly connected node (`connectedNode == node.num`).
- **MQTT nodes excluded from signal**: Signal strength is hidden for nodes heard via MQTT (`viaMqtt == true`), since SNR/RSSI is not meaningful for internet-relayed packets.
- **Future date filtering**: Last heard time is hidden if the timestamp is more than 1 year in the future (guards against clock-skew or corrupted data).
## Architecture
### Layout Structure — Compact
```
┌──────────────────────────────────────────────────────┐
│ ┌──────────┐ Row 1: 🔒 Long Name ★ (fav) │
│ │ Circle │ Row 2: ● Last Heard Time │
│ │ (Short) │ Row 3: [dist] [hops|signal] [ch] │
│ │ Battery │ [role] [telemetry icons] │
│ └──────────┘ │
└──────────────────────────────────────────────────────┘
```
- **Column 1** (fixed width): `CircleText` composable + optional `MaterialBatteryInfo`
- **Column 2** (weight 1f): `Column(verticalArrangement = spacedBy(2.dp))` with up to 3 rows
- Row 1 (always visible): Lock/key icon, long name, favorite star
- Row 2 (toggle: Last Heard Time): Online/offline icon + timestamp via `LastHeardInfo`
- Row 3 (toggle: any of Distance/Hops/Signal/Channel/Role/Telemetry): Combined `Row` of chips separated by `VerticalDivider(height = 15.dp)`
### Layout Structure — Complete
```
┌──────────────────────────────────────────────────────┐
│ ┌──────────┐ Row 1: 🔒 Long Name ★ (fav) │
│ │ Circle │ Row 2: 📡 Connected (if direct) │
│ │ (Short) │ Row 3: ● Last Heard (always shown) │
│ │ Battery │ Row 4: 👤 Role: <name> │
│ └──────────┘ Row 5: 📏 Distance + Bearing │
│ Row 6: Channel + MQTT │
│ Row 7: 📜 Logs: [icons] │
│ Row 8: 🐇 Hops Away OR Signal Gauge │
└──────────────────────────────────────────────────────┘
```
- No user-configurable toggles — all fields shown when data exists
- Signal shown as `LoRaSignalStrengthMeter` gradient gauge (red→green)
- Circle is fixed at 70.dp
### Data Flow
```mermaid
flowchart TD
A["DataStore: nodeListDensity"] --> B{Density?}
B -->|Complete| C[NodeItem]
B -->|Compact| D[NodeItemCompact]
D --> E["DataStore: compact toggles (9 keys)"]
E --> F[lineCount computed property]
F --> G["Circle size: max(36.dp, min(70.dp, 24.dp × lines))"]
E --> H[Conditional field rendering]
H --> I["Row 1: Name — always"]
H --> J["Row 2: Last Heard — toggle"]
H --> K["Row 3: Combined icons — toggles"]
L[NodeLayoutSettings] --> M["Toggle UI — ordered by layout position"]
M --> E
M --> N[Live preview node]
```
### Key Components
| Component | Module / File | Purpose |
|-----------|---------------|---------|
| `NodeListDensity` | `feature/node` | Enum: `COMPLETE` / `COMPACT` |
| `NodeListLayoutPreferences` | `core/prefs` | DataStore keys for density + 9 compact toggles |
| `NodeItemCompact` | `feature/node/component/` | Compact row composable with toggle-driven visibility |
| `NodeItem` | `feature/node/component/` | Complete row composable — no toggles, all data shown |
| `NodeLayoutSettings` | `feature/settings/` | Density picker (SegmentedButton) + compact toggles + live preview |
| `NodeListScreen` | `feature/node/list/` | Parent composable — reads density, delegates to correct item |
| `NodeListHelp` | `feature/node/component/` | Help bottom sheet with signal legend + gradient gauge docs |
| `CircleText` | `core/ui/component/` | Reusable short-name avatar circle |
| `MaterialBatteryInfo` | `core/ui/component/` | Battery level indicator |
| `HopsInfo` | `core/ui/component/` | Hop count chip |
| `DistanceInfo` | `core/ui/component/` | Distance + bearing chip |
| `Snr` / `Rssi` | `core/ui/component/` | Signal quality chips |
| `LastHeardInfo` | `core/ui/component/` | Last heard timestamp chip |
| `LoRaSignalStrengthMeter` | `core/ui/component/` | Gradient gauge (Complete mode) |
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST provide a "Node Layout" section in App Settings with a `SegmentedButton` offering "Complete" and "Compact" density options.
- **FR-002**: The selected density MUST persist across app launches via DataStore using the `nodeListDensity` preference key in `core:prefs`.
- **FR-003**: When "Compact" is selected, the system MUST display 9 toggles (`Switch` composables) in the order they appear in the compact layout: Power, Last Heard Time, Relative Last Heard Time, Distance and Bearing, Hops Away, Signal (Direct Only), Channel, Device Role, Log Icons.
- **FR-004**: Each toggle MUST persist its state via DataStore using the corresponding `NodeListLayoutPreferences` key.
- **FR-005**: All toggles MUST default to `true` (enabled) except "Relative Last Heard Time" which defaults to `false`.
- **FR-006**: The "Relative Last Heard Time" toggle MUST be disabled (grayed out via `enabled = false`) when "Last Heard Time" is toggled off.
- **FR-007**: When "Complete" is selected, the toggle section MUST be replaced with descriptive text: "The Complete layout displays all available node data. Fields with no data are automatically hidden."
- **FR-008**: A live preview MUST render below the toggles using a representative node from Room KMP, reflecting the current density and toggle state in real time via `collectAsState()`.
- **FR-009**: The compact layout MUST render as a two-column `Row`: Column 1 (fixed width: circle + battery), Column 2 (`Modifier.weight(1f)`: `Column` of up to 3 content rows).
- **FR-010**: The short name MUST always render as a `CircleText` composable in compact mode, never as a capsule or alternative shape.
- **FR-011**: The circle diameter MUST scale adaptively: `max(36.dp, min(70.dp, 24.dp × lineCount))` where `lineCount` counts active row groups (1 base + 1 if last-heard enabled + 1 if any combined-row toggle is enabled).
- **FR-012**: Row 1 (name) MUST always display: lock/key encryption icon (from MeshtasticIcons), long name, and favorite star (if favorited). This row is not toggleable.
- **FR-013**: Row 2 (last heard) MUST display when the "Last Heard Time" toggle is on and the node has a valid `lastHeard` timestamp (non-zero, not more than 1 year in the future). It shows an online (green checkmark) or offline (orange moon) icon plus the formatted timestamp via `LastHeardInfo`.
- **FR-014**: Row 3 (combined icons) MUST display as a `Row(horizontalArrangement = spacedBy(6.dp))` with `VerticalDivider(modifier = Modifier.height(15.dp))` between groups, in this order: Distance+Bearing, Hops Away, Signal, Channel, Device Role, Log Icons.
- **FR-015**: Distance and Bearing MUST only render when the toggle is on, the node has positions, the node is not the connected node, and valid location data is available for both the user and the node.
- **FR-016**: Hops Away MUST only render when the toggle is on and `node.hopsAway > 0`.
- **FR-017**: Signal MUST only render when the toggle is on, `node.hopsAway == 0`, `node.snr != 0`, and `node.viaMqtt == false`. The icon color MUST use `getSnrColor(snr, preset)`.
- **FR-018**: Channel MUST only render when the toggle is on and `node.channel > 0` (non-default channel).
- **FR-019**: Device Role MUST render the role's MeshtasticIcons icon plus conditional unmessagable, store-and-forward, and MQTT icons when the toggle is on.
- **FR-020**: Log Icons MUST render when the toggle is on and the node has at least one of: positions, environment metrics, detection sensor metrics, or trace routes. Icons shown: device metrics, positions (mappin), environment, detection sensor, trace routes (signpost) — all from MeshtasticIcons.
- **FR-021**: The complete layout (`NodeItem`) MUST display all data fields unconditionally (no user toggles), hiding fields only when the underlying data is absent.
- **FR-022**: The complete layout MUST show signal strength as a `LoRaSignalStrengthMeter` with a red→orange→yellow→green gradient, not as a single colored icon.
- **FR-023**: The Node List Help sheet MUST document signal strength colors (Good/green, Fair/yellow, Bad/orange, Very Bad/red) with the appropriate MeshtasticIcons signal icon for each.
- **FR-024**: The Node List Help sheet MUST document the gradient signal strength meter used in the Complete layout, with a mini `LinearProgressIndicator` or custom gauge as the visual symbol.
- **FR-025**: Both compact and complete layouts MUST include full TalkBack accessibility via `Modifier.semantics` and `contentDescription` covering: name, connection status, favorite status, last heard, online/offline, role, hops, battery, distance, heading, and signal strength.
- **FR-026**: Compact rows MUST use `Column(verticalArrangement = spacedBy(2.dp))` for consistent tight inter-row spacing.
- **FR-027**: Compact rows MUST have 2.dp top and bottom padding. Complete rows MUST have 3.dp top and bottom padding.
### Non-Functional Requirements
- **NFR-001**: Toggle state changes MUST reflect in the UI within one recomposition cycle (no perceptible delay).
- **NFR-002**: The compact layout MUST render smoothly at 60fps when scrolling a `LazyColumn` of 200+ nodes.
- **NFR-003**: DataStore preference keys MUST use the `NodeListLayoutPreferences` enum values to prevent key string drift.
- **NFR-004**: The compact view MUST use `LazyColumn` with stable `key` parameters for efficient rendering and item reuse.
### Signal Strength Thresholds
Signal color is determined by `getSnrColor(snr, preset)` relative to the active modem preset's SNR limit:
| Condition | Color | Help Label |
|-----------|-------|------------|
| SNR > preset limit | Green | Good |
| SNR < preset limit AND SNR > (limit 5.5) | Yellow | Fair |
| SNR ≥ (limit 7.5) | Orange | Bad |
| SNR < (limit 7.5) | Red | Very Bad |
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A user can switch between Complete and Compact density and see the node list re-render within 1 second.
- **SC-002**: All 9 compact toggles persist across app launches and correctly show/hide their corresponding UI elements.
- **SC-003**: The live preview in Settings accurately reflects the current toggle configuration for both density modes.
- **SC-004**: The help sheet documents all signal strength indicators including the 4 color-coded icon entries and the gradient gauge entry.
- **SC-005**: Compact mode reduces visible row height by at least 40% compared to Complete mode for a node with full data.
- **SC-006**: TalkBack reads a complete, meaningful description for nodes in both Complete and Compact layouts.
## Toggle Reference
| Toggle Label | DataStore Key | Default | Layout Position | Data Condition |
|---|---|---|---|---|
| Power | `shouldShowPower` | `true` | Column 1, below circle | `node.batteryLevel != null` |
| Last Heard Time | `shouldShowLastHeard` | `true` | Row 2 | Valid `lastHeard` timestamp |
| Relative Last Heard Time | `lastHeardIsRelative` | `false` | Row 2 (format only) | `shouldShowLastHeard == true` |
| Distance and Bearing | `shouldShowLocation` | `true` | Row 3, position 1 | Node has positions + not connected node + valid location |
| Hops Away | `shouldShowHops` | `true` | Row 3, position 2 | `hopsAway > 0` |
| Signal (Direct Only) | `shouldShowSignal` | `true` | Row 3, position 3 | `hopsAway == 0` + `snr != 0` + `!viaMqtt` |
| Channel | `shouldShowChannel` | `true` | Row 3, position 4 | `channel > 0` |
| Device Role | `shouldShowRole` | `true` | Row 3, position 5 | Always (role defaults to 0) |
| Log Icons | `shouldShowTelemetry` | `true` | Row 3, position 6 | Node has positions/environment/sensor/traces |
## Assumptions
- The node list data model (`Node` in `core:model`, backed by `NodeEntity` in `core:database`) is fully populated by the packet processing pipeline. The layout engine only reads — it never writes to the model.
- `CircleText`, `MaterialBatteryInfo`, `HopsInfo`, `DistanceInfo`, `Snr`, `Rssi`, and `LastHeardInfo` are pre-existing reusable components in `core:ui`. The layout engine composes them but does not modify them.
- `getSnrColor` uses modem-preset-relative thresholds, not absolute SNR values. The user's active modem preset is read from DataStore preferences.
- The live preview uses the first node from a Room KMP query sorted by `lastHeard` descending — it requires at least one node in the database to render.
- The Complete layout is intentionally not configurable — its purpose is to show everything, acting as the baseline reference.
- All business logic and UI composables reside in `commonMain` source set. No platform-specific code is required for this feature.
- String resources for toggle labels and help text are added to `core/resources/src/commonMain/composeResources/values/strings.xml` using `stringResource(Res.string.key)`.

View File

@@ -0,0 +1,138 @@
# Tasks — Node List Layout
## Phase 0: Design Standards Gate (Blocking)
**Purpose**: Review Meshtastic design standards before shipping any new UI for node list density and settings.
- [ ] NL-T000 `[UI-GATE]` Review `.skills/design-standards/SKILL.md` and upstream Meshtastic design standards; record constraints for `NodeItemCompact`, `NodeLayoutSettings`, density picker, and `NodeListHelp` sheet styling.
**Dependencies**: None — this phase blocks all UI work.
---
## Phase 1: Preferences and Data Model
**Purpose**: Define the density enum and add all DataStore preference keys.
- [ ] NL-T001 [P] Create `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/NodeListDensity.kt` with `enum class NodeListDensity { COMPLETE, COMPACT }`.
- [ ] NL-T002 [P] Add `NodeListLayoutPreferences` enum or object in `core/prefs` defining all 10 DataStore keys (`nodeListDensity` + 9 compact toggles) with their defaults.
- [ ] NL-T003 Add DataStore preference accessors for all 10 keys in `UiPrefsImpl.kt` as `StateFlow<Boolean>` / `StateFlow<NodeListDensity>` with eager seeding.
- [ ] NL-T004 Add string resources for all toggle labels, help text, density option labels, and section headers to `strings.xml`. Run `python3 scripts/sort-strings.py`.
**Dependencies**: None — this phase can start immediately.
**Parallel**: NL-T001 and NL-T002 are independent of each other.
---
## Phase 2: Compact Row Composable
**Purpose**: Build the new `NodeItemCompact` composable with toggle-driven field visibility and adaptive circle sizing.
- [ ] NL-T010 [P] Create `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeItemCompact.kt` with the two-column layout structure (Column 1: circle + battery, Column 2: up to 3 rows).
- [ ] NL-T011 Implement Row 1 (always visible): lock/key icon, long name, favorite star.
- [ ] NL-T012 Implement Row 2 (toggle: `shouldShowLastHeard`): online/offline icon + timestamp via `LastHeardInfo`, with relative time support via `lastHeardIsRelative`.
- [ ] NL-T013 Implement Row 3 combined icons `Row` with `VerticalDivider` separators: Distance+Bearing, Hops Away, Signal, Channel, Device Role, Log Icons — each gated by its toggle and data condition.
- [ ] NL-T014 Implement adaptive circle sizing: `max(36.dp, min(70.dp, 24.dp × lineCount))` based on active row groups.
- [ ] NL-T015 Implement conditional battery rendering below circle (toggle: `shouldShowPower`).
- [ ] NL-T016 Add `Modifier.semantics` / `contentDescription` for full TalkBack coverage on compact rows.
- [ ] NL-T017 Add future date guard: hide last heard if timestamp is > 1 year in the future.
**Dependencies**: Requires Phase 1 (NL-T001NL-T004).
**Parallel**: NL-T011NL-T017 can be developed in parallel once NL-T010 scaffold exists.
---
## Phase 3: Complete Row Refactor
**Purpose**: Ensure the existing `NodeItem` serves cleanly as the Complete layout counterpart.
- [ ] NL-T020 Review existing `NodeItem.kt` and confirm it displays all fields unconditionally (no toggle logic). Refactor if needed to ensure clarity.
- [ ] NL-T021 Ensure `LoRaSignalStrengthMeter` gradient gauge is used for signal display in Complete mode (not just a colored icon).
- [ ] NL-T022 Ensure Complete rows have 3.dp top/bottom padding.
- [ ] NL-T023 Add `Modifier.semantics` / `contentDescription` for full TalkBack coverage on complete rows (if not already present).
**Dependencies**: None — can run in parallel with Phase 2.
---
## Phase 4: NodeList Density Switching
**Purpose**: Wire the density preference into the node list so it delegates to the correct row composable.
- [ ] NL-T030 Modify `NodeListViewModel` to expose `nodeListDensity: StateFlow<NodeListDensity>` and all 9 compact toggle flows.
- [ ] NL-T031 Modify `NodeListScreen` to collect density state and delegate to `NodeItem` (Complete) or `NodeItemCompact` (Compact) per row.
- [ ] NL-T032 Ensure `LazyColumn` uses stable `key` parameters for both layout variants.
- [ ] NL-T033 Verify smooth scrolling at 60fps with 200+ nodes in Compact mode.
**Dependencies**: Requires Phase 2 (NL-T010+) and Phase 3 (NL-T020+).
---
## Phase 5: Settings UI
**Purpose**: Build the Node Layout settings section with density picker, toggles, and live preview.
- [ ] NL-T040 [P] Create `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/app/NodeLayoutSettings.kt` with `SegmentedButton` for Complete/Compact density selection.
- [ ] NL-T041 Add 9 `Switch` composable toggles (ordered by layout position) that appear only when Compact is selected.
- [ ] NL-T042 Add descriptive text ("The Complete layout displays all available node data...") when Complete is selected.
- [ ] NL-T043 Implement "Relative Last Heard Time" toggle disabled state when "Last Heard Time" is off.
- [ ] NL-T044 Implement live preview composable below toggles using first node from Room KMP query (sorted by `lastHeard` descending), with placeholder when database is empty.
- [ ] NL-T045 Integrate `NodeLayoutSettings` into the existing App Settings screen in `feature/settings`.
**Dependencies**: Requires Phase 1 (NL-T002NL-T003) for DataStore keys. Can start NL-T040 scaffold in parallel with Phase 2.
---
## Phase 6: Help Sheet
**Purpose**: Add the signal strength help documentation accessible from the node list.
- [ ] NL-T050 [P] Create `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeListHelp.kt` as a `ModalBottomSheet`.
- [ ] NL-T051 Add "Node Details" section with 4 signal strength entries: Good (green), Fair (yellow), Bad (orange), Very Bad (red) with MeshtasticIcons signal icons.
- [ ] NL-T052 Add gradient signal strength meter documentation entry with a mini gauge visual.
- [ ] NL-T053 Add help button (?) trigger to `NodeListScreen` that opens the help sheet.
**Dependencies**: None — can run in parallel with Phases 25.
---
## Phase 7: Testing and Verification
**Purpose**: Validate all requirements and ensure no regressions.
- [ ] NL-T060 Write unit tests for `NodeListDensity` enum and `lineCount` calculation logic.
- [ ] NL-T061 Write unit tests for DataStore preference defaults (all `true` except `lastHeardIsRelative`).
- [ ] NL-T062 Write unit tests for edge cases: future date filtering, channel 0 hiding, signal/hops mutual exclusivity, connected node distance exclusion, MQTT signal exclusion.
- [ ] NL-T063 Write Compose UI tests for `NodeItemCompact` with various toggle combinations.
- [ ] NL-T064 Write Compose UI tests for density switching in `NodeListScreen`.
- [ ] NL-T065 Run `./gradlew :feature:node:allTests :feature:settings:allTests` to validate.
- [ ] NL-T066 Run `./gradlew spotlessApply detekt assembleDebug test allTests` for full verification.
**Dependencies**: Requires Phases 16.
---
## Dependency Graph
```
Phase 1 (Preferences) ──┬──→ Phase 2 (Compact Row) ──┬──→ Phase 4 (Density Switching) ──→ Phase 7 (Testing)
│ │
├──→ Phase 3 (Complete Refactor)┘
└──→ Phase 5 (Settings UI) ──→ Phase 7
Phase 6 (Help Sheet) ────────────────────────────────────→ Phase 7
```
## Task Summary
| Phase | Tasks | Parallel Opportunities |
|-------|-------|----------------------|
| 1. Preferences | 4 | NL-T001 ∥ NL-T002 |
| 2. Compact Row | 8 | NL-T011NL-T017 after NL-T010 |
| 3. Complete Refactor | 4 | Entire phase ∥ Phase 2 |
| 4. Density Switching | 4 | Sequential |
| 5. Settings UI | 6 | NL-T040 scaffold ∥ Phase 2 |
| 6. Help Sheet | 4 | Entire phase ∥ Phases 25 |
| 7. Testing | 7 | NL-T060NL-T064 parallelizable |
| **Total** | **37** | **21 can run in parallel** |

View File

@@ -0,0 +1,43 @@
# Specification Quality Checklist: App Documentation (Android/KMP)
**Purpose**: Validate the Android/KMP-adapted spec before implementation begins
**Created**: 2026-05-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] Scope is clear: web docs, in-app docs, search, AI fallback, and automation are explicitly bounded.
- [x] The spec is adapted to Meshtastic-Android architecture (KMP, Navigation 3, Koin, Gradle, flavors).
- [x] User-facing outcomes are described separately from implementation mechanics.
- [x] Platform-specific behavior differences (Android vs Desktop/iOS vs flavor gating) are explicitly called out.
- [x] No placeholder sections or unresolved TODO markers remain.
## Requirement Completeness
- [x] Functional requirements cover docs authoring, rendering, packaging, routing, AI, screenshots, and CI automation.
- [x] Requirements include measurable constraints for performance, accessibility, bundle size, and release versioning.
- [x] Edge cases cover missing assets, unsupported AI environments, stale deep links, and degraded screenshot automation.
- [x] The deep-link contract, keyword-index schema, and CI workflow contract are defined as separate artifacts.
- [x] Search and AI fallback behavior are specified for unsupported targets and flavors.
## Android/KMP Adaptation Checks
- [x] WebView plus cross-platform renderer abstraction is specified for in-app rendering.
- [x] Gemini Nano / AICore / ML Kit GenAI expectations and explicit fallbacks are defined.
- [x] Gradle-generated resources/assets are used for bundling.
- [x] Navigation 3 typed routes are used throughout.
- [x] Guidance is derived from existing UI copy, warnings, and callouts for in-app tips.
- [x] `MeshtasticIcons` and vector assets are used for all iconography.
## Readiness
- [x] `feature/docs/` module placement and plugin usage are specified.
- [x] Docs content sources reference actual Meshtastic-Android modules and file paths.
- [x] The plan and tasks phases align with the requested Phase 08 breakdown.
- [x] The spec is ready for implementation planning and execution without further clarification.
## Notes
- Some technical detail remains intentionally explicit because this is a platform-adaptation spec, not a product-only brief.
- The AI path is intentionally flavor- and runtime-gated so the spec remains compatible with both `google` and `fdroid` builds.
- Screenshot automation is specified with a preferred path (Roborazzi) and an acceptable alternative (Paparazzi) because the repository has no active screenshot framework today.

View File

@@ -0,0 +1,159 @@
# CI Workflow Contract: App Documentation (Android/KMP)
## Workflow 1: `docs-deploy.yml` (continuous beta publish)
**Trigger**: `push` to `main`
**Runner**: `ubuntu-24.04`
**Primary goal**: Build and publish `/beta/` docs, validate bundled assets, and surface screenshot changes via bot PRs.
### Required steps (in order)
| Step | Action | Expected output |
|------|--------|-----------------|
| Checkout | `actions/checkout@v6` with full history as needed and `submodules: true` | Repository workspace |
| JDK / Gradle setup | Reuse existing project setup action or equivalent | JDK 21 + Gradle cache |
| Docs bundle generation | `./gradlew generateDocsBundle -Pdocs.channel=beta -Pci=true` | Generated HTML/resources/index |
| Docs validation | `./gradlew validateDocsBundle -Pdocs.channel=beta -Pci=true` | Schema, size, and asset pass/fail |
| Screenshot generation/validation | `./gradlew recordDocsScreenshots -Pci=true` or the configured equivalent | Updated PNGs or validation report |
| Site artifact generation | `./gradlew publishDocsSite -Pdocs.channel=beta -Pci=true` | `_site/beta/` output |
| Screenshot diff handling | Detect changed PNGs and open/update bot PR | Reviewable screenshot changes |
| Upload Pages artifact | `actions/upload-pages-artifact` | Deployable artifact |
| Deploy Pages | `actions/deploy-pages` | Updated `/beta/` site |
### Required behavior
- The workflow MUST fail if docs generation fails.
- The workflow MUST fail if `validateDocsBundle` reports a schema error, missing asset, or bundle size above the hard limit.
- The workflow SHOULD warn (not fail) when screenshot automation is explicitly configured in validation-only mode, but it MUST still fail if referenced screenshot files are missing.
- The workflow MUST mark beta pages as pre-release in the published output.
- The workflow MUST avoid direct commits to `main` when screenshots change.
### Screenshot PR behavior
If screenshot PNGs differ after the screenshot step:
1. Create or update a bot branch (for example `bot/update-doc-screenshots`).
2. Commit only screenshot asset changes and any generated screenshot manifest files.
3. Open or update a PR targeting `main`.
4. Continue publishing docs only if the published site does not depend on uncommitted screenshot changes; otherwise fail with a clear message.
---
## Workflow 2: `docs-release.yml` (versioned stable release)
**Trigger**: `push` tags matching `v*.*.*`
**Runner**: `ubuntu-24.04`
**Primary goal**: Publish immutable docs for a released app version and refresh the version selector.
### Required steps (in order)
| Step | Action | Expected output |
|------|--------|-----------------|
| Checkout | `actions/checkout@v6` with tag context and `submodules: true` | Repository workspace |
| Extract version | Derive `X.Y.Z` from `vX.Y.Z` | Release version string |
| JDK / Gradle setup | Reuse existing project setup action or equivalent | JDK 21 + Gradle cache |
| Docs bundle generation | `./gradlew generateDocsBundle -Pdocs.channel=release -Pdocs.version=X.Y.Z -Pci=true` | Versioned generated docs |
| Docs validation | `./gradlew validateDocsBundle -Pdocs.version=X.Y.Z -Pci=true` | Validation pass/fail |
| Screenshot generation/validation | Run configured screenshot task | Release-aligned assets |
| Site artifact generation | `./gradlew publishDocsSite -Pdocs.version=X.Y.Z -Pci=true` | `_site/vX.Y.Z/` output |
| Update versions manifest | Append or update `docs/_data/versions.yml` | New version visible in selector |
| Refresh `/latest/` redirect | Point `/latest/` to `/vX.Y.Z/` | Stable default |
| Upload Pages artifact | `actions/upload-pages-artifact` | Deployable artifact |
| Deploy Pages | `actions/deploy-pages` | Updated stable docs |
### Required behavior
- Release docs MUST publish to `/vX.Y.Z/`.
- `/latest/` MUST point to the newest stable release.
- `/beta/` MUST remain separate and must not be overwritten by release publishing.
- The workflow MUST fail if the version manifest cannot be updated consistently.
---
## Gradle Task Interface Contract
### `generateDocsBundle`
**Purpose**: Convert markdown source into packaged docs artifacts and metadata.
**Inputs**:
- `docs/**/*.md`
- shared CSS/callout templates
- screenshot assets or manifests
- optional properties: `docs.channel`, `docs.version`
**Outputs**:
- generated HTML for each page
- optional bundled markdown mirror
- `index.json`
- generated resource/asset directories for `feature/docs`
**Failure conditions**:
- markdown parse failure
- invalid frontmatter
- missing required page metadata
### `validateDocsBundle`
**Purpose**: Enforce correctness and size constraints.
**Checks**:
- `index.json` matches `contracts/keyword-index-schema.json`
- every index entry maps to a packaged page
- every referenced screenshot asset exists
- bundle size warning at 8 MB, hard failure at 10 MB
- nav ordering and duplicate page IDs are valid
### `recordDocsScreenshots`
**Purpose**: Refresh screenshot assets using the chosen Android screenshot tool.
**Expected behavior**:
- Preferred implementation uses Roborazzi.
- Acceptable alternative uses Paparazzi.
- Output PNGs are written to the docs asset staging area or copied there immediately after generation.
- When running in validation-only mode, the task MUST still verify that referenced screenshots exist.
### `publishDocsSite`
**Purpose**: Assemble the final Pages artifact.
**Outputs**:
- `_site/beta/` for beta builds
- `_site/vX.Y.Z/` for tagged releases
- refreshed root redirects / version-selector inputs as required
---
## Environment Expectations
- JDK 21 is required.
- Android SDK availability is required only if the chosen screenshot task depends on Android tooling.
- CI should reuse the repositorys existing Gradle setup and cache strategy where possible.
- Commands must use explicit task paths or root lifecycle tasks; avoid ambiguous shorthand.
---
## Failure Modes
| Failure | Expected behavior |
|---------|-------------------|
| Markdown conversion fails | Workflow fails; no publish |
| Keyword-index schema invalid | Workflow fails; no publish |
| Screenshot task unavailable but validation-only mode is configured | Workflow warns and continues only if all referenced assets are present |
| Screenshot task fails in required-record mode | Workflow fails; no publish |
| Bundle size > 8 MB | Warning annotation |
| Bundle size > 10 MB | Hard failure; no publish |
| Pages upload/deploy failure | Workflow fails after artifact step |
| Bot PR creation fails | Workflow fails or emits explicit actionable error; silent failure is not allowed |
---
## Minimum Observability
The workflows should emit clear logs for:
- number of generated pages
- number of indexed pages
- total bundle size
- number of screenshot assets processed
- whether AI-specific docs sections were included unchanged or updated
- release version / channel being published

View File

@@ -0,0 +1,102 @@
# Deep Link Contract: Help & Documentation
## Public URLs
Primary user-facing contract:
```text
meshtastic://meshtastic/settings/helpDocs
```
Specific page form:
```text
meshtastic://meshtastic/settings/helpDocs/{pageId}
```
Accepted compatibility aliases:
```text
meshtastic://meshtastic/settings/help-docs
meshtastic://meshtastic/settings/help-docs/{pageId}
```
## Routing Behavior
| Component | Value |
|-----------|-------|
| Scheme | `meshtastic` |
| Host | `meshtastic` |
| Path | `/settings/helpDocs` or `/settings/helpDocs/{pageId}` |
| Normalized internal slug | `help-docs` |
| Query params | none in MVP |
## Typed Route Mapping
Planned additions in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`:
```kotlin
@Serializable data object HelpDocs : SettingsRoute
@Serializable data class HelpDocPage(val pageId: String) : SettingsRoute
```
### Routing rules
| Incoming URI | Resulting backstack |
|--------------|---------------------|
| `meshtastic://meshtastic/settings/helpDocs` | `listOf(SettingsRoute.SettingsGraph(null), SettingsRoute.HelpDocs)` |
| `meshtastic://meshtastic/settings/helpDocs/messages-and-channels` | `listOf(SettingsRoute.SettingsGraph(null), SettingsRoute.HelpDocs, SettingsRoute.HelpDocPage("messages-and-channels"))` |
| `meshtastic://meshtastic/settings/help-docs` | same as above after normalization |
## Dispatch Path
1. App receives the deep link as a `CommonUri`.
2. `DeepLinkRouter.route(uri)` normalizes hostless and host-based forms.
3. `settings` is resolved as the first path segment.
4. `helpDocs` or `help-docs` is normalized to the docs route.
5. `SettingsRoute.SettingsGraph(destNum = null)` is added as the root backstack entry.
6. `SettingsRoute.HelpDocs` is added.
7. If a `{pageId}` segment is present, `SettingsRoute.HelpDocPage(pageId)` is added.
8. `feature/settings/.../SettingsNavigation.kt` and `feature/docs/.../DocsNavigation.kt` render the docs browser/page.
## Required Code Changes
| File | Change |
|------|--------|
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` | Add `HelpDocs` and `HelpDocPage` routes |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` | Map `helpDocs` / `help-docs` to docs routes |
| `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` | Add Settings entry point and docs destination wiring |
| `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt` | Register docs browser and page destinations |
| `README.md` | Document `meshtastic://meshtastic/settings/helpDocs` |
## Expected UX
- Root docs deep link opens the Help & Documentation home screen inside Settings.
- Specific page deep links open the targeted page while preserving a valid backstack.
- Unknown `{pageId}` values open the docs home screen and surface a "page not found" message with suggestions.
## Acceptance Tests
```kotlin
@Test
fun `root help docs deep link routes to settings docs`() {
assertEquals(
listOf(SettingsRoute.SettingsGraph(destNum = null), SettingsRoute.HelpDocs),
route("/settings/helpDocs"),
)
}
@Test
fun `page help docs deep link routes to docs page`() {
assertEquals(
listOf(
SettingsRoute.SettingsGraph(destNum = null),
SettingsRoute.HelpDocs,
SettingsRoute.HelpDocPage("messages-and-channels"),
),
route("/settings/helpDocs/messages-and-channels"),
)
}
```
Where `route(path)` mirrors the style already used in `core/navigation/src/commonTest/kotlin/org/meshtastic/core/navigation/DeepLinkRouterTest.kt`.

View File

@@ -0,0 +1,89 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MeshtasticAndroidDocKeywordIndex",
"description": "Build-time generated keyword index used by in-app docs search and Gemini Nano retrieval.",
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"title",
"section",
"resourcePath",
"navOrder",
"keywords",
"charCount"
],
"properties": {
"id": {
"type": "string",
"description": "Stable page slug.",
"minLength": 1,
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
"examples": ["messages-and-channels", "architecture", "firmware"]
},
"title": {
"type": "string",
"description": "Human-readable page title shown in navigation and search.",
"minLength": 1,
"examples": ["Messages & Channels", "Architecture Overview"]
},
"section": {
"type": "string",
"description": "Top-level documentation section.",
"enum": ["user", "developer"]
},
"resourcePath": {
"type": "string",
"description": "Canonical packaged resource path for the page's primary render artifact.",
"minLength": 1,
"examples": [
"docs/user/messages-and-channels.html",
"docs/developer/architecture.html"
]
},
"navOrder": {
"type": "integer",
"description": "Frontmatter-derived ordering value used for section sorting.",
"minimum": 0,
"examples": [1, 5, 999]
},
"keywords": {
"type": "array",
"description": "Normalized search and retrieval terms extracted or curated for the page.",
"minItems": 1,
"maxItems": 50,
"items": {
"type": "string",
"minLength": 2
},
"examples": [
["channel", "message", "encryption", "direct", "broadcast"],
["navigation", "navkey", "deeplink", "routes"]
]
},
"aliases": {
"type": "array",
"description": "Optional alternate terms or renamed slugs used for matching.",
"default": [],
"maxItems": 20,
"items": {
"type": "string",
"minLength": 1
},
"examples": [
["channels", "direct-messages"],
["deeplinks", "navigation-3"]
]
},
"charCount": {
"type": "integer",
"description": "Plain-text character count used for prompt budgeting.",
"minimum": 1,
"examples": [1820, 7345]
}
}
}
}

View File

@@ -0,0 +1,323 @@
# Data Model: App Documentation (Android/KMP)
## Overview
This feature introduces no Room entities and no durable database tables. Documentation content is packaged as build-time resources/assets and loaded into memory at runtime. Preferences are optional and limited to lightweight UX state (for example, remembering the last viewed section); the documentation corpus itself is never stored in Room.
The core runtime model is expressed as Kotlin `data class` and `sealed interface` types that can live in `feature/docs/src/commonMain/kotlin/...` and be shared across Android, Desktop, and iOS.
---
## Runtime Entities
### 1. `DocSection`
Represents the top-level documentation buckets shown on the website and inside the app.
```kotlin
@Serializable
sealed interface DocSection {
@Serializable data object UserGuide : DocSection
@Serializable data object DeveloperGuide : DocSection
}
```
| Property | Type | Notes |
|----------|------|-------|
| `id` | derived | Stable logical ID such as `user` or `developer` |
| `displayName` | derived | UI label shown in TOC/search grouping |
| `resourceDir` | derived | `docs/user/` or `docs/developer/` |
**Validation rules**
- Must map 1:1 to a top-level docs directory.
- Must be stable across releases so deep links and keyword index entries remain valid.
---
### 2. `DocPage`
Represents a single documentation page regardless of how it is rendered on a target.
```kotlin
@Serializable
data class DocPage(
val id: String,
val title: String,
val section: DocSection,
val navOrder: Int,
val resourcePath: String,
val keywords: List<String>,
val aliases: List<String> = emptyList(),
val charCount: Int,
)
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | `String` | Stable slug such as `messages-and-channels` |
| `title` | `String` | Human-readable page title |
| `section` | `DocSection` | User or Developer Guide |
| `navOrder` | `Int` | Intended sort order within the section |
| `resourcePath` | `String` | Canonical packaged resource path, for example `docs/user/messages-and-channels.html` |
| `keywords` | `List<String>` | Search/retrieval vocabulary generated at build time |
| `aliases` | `List<String>` | Optional alternative search terms or renamed page slugs |
| `charCount` | `Int` | Plain-text character count used for token budgeting |
**Validation rules**
- `id` must be unique across the full corpus.
- `navOrder` must be non-negative.
- `resourcePath` must resolve in the packaged bundle for every supported target.
- `charCount` must be `> 0`.
**State transitions**
- Immutable after load.
- Replaced only when the shipped app version changes.
---
### 3. `DocPageContent`
Decouples metadata from actual content so different targets can choose HTML or markdown rendering.
```kotlin
data class DocPageContent(
val page: DocPage,
val html: String? = null,
val markdown: String? = null,
val cssPath: String? = null,
)
```
| Field | Type | Notes |
|-------|------|-------|
| `page` | `DocPage` | Metadata and lookup info |
| `html` | `String?` | Preferred on Android/WebView and for site output parity |
| `markdown` | `String?` | Optional fallback for Compose markdown rendering on Desktop/iOS |
| `cssPath` | `String?` | Shared stylesheet path for HTML surfaces |
**Rendering rules**
- Android normally prefers `html`.
- Desktop/iOS may prefer `markdown` for Compose rendering, or `html` if an embedded browser implementation is chosen.
- At least one of `html` or `markdown` must be present for each page.
---
### 4. `DocBundle`
Runtime aggregate of the full packaged documentation corpus.
```kotlin
data class DocBundle(
val pages: List<DocPage>,
val pageIndex: Map<String, DocPage>,
val bundleVersion: String,
val generatedAt: String,
val totalBytes: Long,
)
```
| Field | Type | Description |
|-------|------|-------------|
| `pages` | `List<DocPage>` | All bundled pages |
| `pageIndex` | `Map<String, DocPage>` | O(1) lookup by page ID |
| `bundleVersion` | `String` | App/docs version identifier (`beta`, `2.8.0`, etc.) |
| `generatedAt` | `String` | ISO timestamp written by the build task |
| `totalBytes` | `Long` | Total packaged size for size-budget enforcement |
**Primary operations**
```kotlin
interface DocBundleLoader {
suspend fun load(): DocBundle
suspend fun readPage(pageId: String): DocPageContent?
fun pagesBySection(section: DocSection): List<DocPage>
}
```
**Invariants**
- `pagesBySection()` sorts by `navOrder`, then title.
- `pageIndex.keys == pages.map { it.id }.toSet()`.
- `totalBytes <= 10_485_760` for release-ready bundles.
---
### 5. `KeywordIndexEntry`
Build-time artifact decoded at runtime for keyword search and AI retrieval.
```kotlin
@Serializable
data class KeywordIndexEntry(
val id: String,
val title: String,
val section: String,
val resourcePath: String,
val navOrder: Int,
val keywords: List<String>,
val aliases: List<String> = emptyList(),
val charCount: Int,
)
```
| Field | Type | Description |
|-------|------|-------------|
| `id` | `String` | Matches `DocPage.id` |
| `title` | `String` | Display title |
| `section` | `String` | `user` or `developer` |
| `resourcePath` | `String` | Packaged path to HTML/markdown asset |
| `navOrder` | `Int` | Frontmatter-derived ordering |
| `keywords` | `List<String>` | Generated retrieval terms |
| `aliases` | `List<String>` | Optional renamed terms and synonyms |
| `charCount` | `Int` | Plain-text size used for token budgeting |
**Validation rules**
- Must match the JSON schema in `contracts/keyword-index-schema.json`.
- Every entry must correspond to exactly one bundled page.
---
### 6. `DocSearchQuery` and `DocSearchResult`
Used by the shared keyword-search fallback and by Gemini Nano retrieval pre-ranking.
```kotlin
data class DocSearchQuery(
val rawText: String,
val normalizedTerms: List<String>,
)
data class DocSearchResult(
val page: DocPage,
val score: Int,
val matchedTerms: List<String>,
)
```
| Type | Purpose |
|------|---------|
| `DocSearchQuery` | Normalized user input after lowercasing, tokenization, alias expansion, and stop-word removal |
| `DocSearchResult` | Ranked page match used in UI search results and AI context selection |
**Ranking rules**
- Exact keyword matches score higher than alias matches.
- Title matches outrank body-keyword matches.
- `navOrder` breaks ties within a section.
---
### 7. `AIDocAssistant`
Shared abstraction over the platform-specific docs assistant.
```kotlin
interface AIDocAssistant {
suspend fun answer(question: String): AIDocAssistantResult
}
```
Possible runtime result model:
```kotlin
sealed interface AIDocAssistantResult {
data class Success(
val answer: String,
val sourcePages: List<DocPage>,
val usedOnDeviceModel: Boolean,
) : AIDocAssistantResult
data class Fallback(
val message: String,
val suggestedPages: List<DocPage>,
) : AIDocAssistantResult
data class Error(
val reason: DocsAiError,
val suggestedPages: List<DocPage> = emptyList(),
) : AIDocAssistantResult
}
```
Associated error model:
```kotlin
sealed interface DocsAiError {
data object UnsupportedPlatform : DocsAiError
data object UnsupportedFlavor : DocsAiError
data object ModelUnavailable : DocsAiError
data object Busy : DocsAiError
data object TokenBudgetExceeded : DocsAiError
data object Unknown : DocsAiError
}
```
**Platform behavior**
- Android `google` flavor may return `Success` using Gemini Nano.
- `fdroid`, Desktop, and iOS normally return `Fallback` or `UnsupportedPlatform` and provide suggested pages.
---
### 8. `AIDocAssistantSessionState`
UI state for the Chirpy conversation surface.
```kotlin
data class AIDocAssistantSessionState(
val messages: List<ChirpyMessage>,
val isLoading: Boolean,
val draftQuestion: String,
)
@Serializable
data class ChirpyMessage(
val id: String,
val role: ChirpyRole,
val text: String,
val sourcePageIds: List<String> = emptyList(),
)
@Serializable
enum class ChirpyRole { USER, ASSISTANT, SYSTEM }
```
**Lifecycle**
- Session state is ephemeral and resets when the screen/process is recreated unless explicitly made saveable.
- Messages are not persisted to Room.
---
## Build-Time Artifacts
| Artifact | Produced By | Consumed By | Notes |
|----------|-------------|-------------|-------|
| `docs/**/*.md` | Human-authored | Jekyll + Gradle docs task | Canonical source |
| Generated HTML pages | Gradle docs generation task | Android `WebView`, optional Desktop/iOS embedded browser, GitHub Pages output | Site-parity artifact |
| Optional bundled markdown mirror | Gradle docs generation task | Desktop/iOS Compose renderer | Keeps shared renderer path available |
| `index.json` | Gradle docs generation task | Search, AI retrieval, bundle loader | Must match schema contract |
| `versions.yml` | Release workflow | Jekyll version selector | Web-only manifest |
| Screenshot PNGs | Roborazzi/Paparazzi/manual capture sync | Markdown pages, packaged docs assets | Inline illustrations |
| `docs.css` | Hand-authored/shared | HTML pages | Light/dark + callouts |
| Chirpy SVG/vector | Design assets | Compose UI | Branded assistant avatar |
---
## Relationships
```text
DocBundle
├── pages: List<DocPage>
├── pageIndex: Map<String, DocPage>
└── page content files (HTML and/or markdown)
KeywordIndexEntry --1:1--> DocPage
DocSearchQuery --ranks--> DocSearchResult --references--> DocPage
AIDocAssistant --uses--> KeywordIndexEntry + DocPageContent
AIDocAssistantSessionState --contains--> ChirpyMessage --references--> DocPage IDs
```
---
## Persistence Notes
- **No Room tables** are required for documentation content.
- **No migration story** is required for docs content because the corpus is versioned with the app binary.
- Optional UX-only settings (for example, last-opened section) may live in `core:prefs`, but they are intentionally excluded from this features core data model.

View File

@@ -0,0 +1,381 @@
# Implementation Plan: App Documentation (Android/KMP)
**Branch**: `003-app-docs-markdown` | **Date**: 2026-05-07 | **Spec**: [spec.md](spec.md)
**Status**: Not Started
**Input**: Feature specification from `specs/003-app-docs-markdown/spec.md`
## Summary
Build a complete documentation system for Meshtastic-Android that includes:
1. a versioned GitHub Pages Jekyll site,
2. an offline in-app documentation browser inside Settings,
3. keyword search across the bundled docs corpus, and
4. an Android-only on-device AI assistant powered by Gemini Nano on supported devices, with graceful fallback elsewhere.
The implementation centers on a new `feature/docs/` KMP module using the `meshtastic.kmp.feature` plugin. Shared state, models, search, and most UI live in `commonMain`. Android-specific HTML rendering and Gemini integration live behind platform or flavor abstractions. Build-time markdown conversion is handled by Gradle using `flexmark-java`, and bundled output is packaged through generated resources/assets.
## Technical Context
**Language/Version**: Kotlin 2.3.x, Gradle Kotlin DSL, JDK 21
**Primary Dependencies**: Compose Multiplatform, Navigation 3, Koin annotations, `flexmark-java` (or `commonmark-java` fallback), existing `multiplatform-markdown-renderer`, Android `WebView`, optional Gemini Nano via AICore / ML Kit GenAI Prompt API
**Storage**: Bundled resources/assets only; no Room persistence for docs content
**Testing**: KMP unit tests, Android host/unit tests, optional Roborazzi/Paparazzi screenshot tests, workflow validation
**Target Platforms**: Android, Desktop JVM, iOS
**Project Type**: KMP feature module + build logic + GitHub Pages deployment
**Performance Goals**: TOC render < 1 second; AI response < 5 seconds on supported devices; docs workflow < 10 minutes
**Constraints**: Bundle <= 10 MB, no `android.*` in `commonMain`, typed NavKey routes only, `fdroid` flavor must remain functional without proprietary AI stack
**Scale/Scope**: ~14 User Guide pages, ~8 Developer Guide pages, one keyword index JSON, two GitHub Actions workflows, one new KMP module
## Architecture / Constitution Check
| Principle | Status | Notes |
|-----------|--------|-------|
| KMP boundaries respected | ✅ PASS | Shared models/search/UI live in `commonMain`; Android WebView + AI live outside `commonMain`. |
| Navigation 3 typed routes | ✅ PASS | Docs routes are added to `Routes.kt` and wired through `SettingsGraph`. |
| Koin annotations first | ✅ PASS | `feature/docs` will export a `FeatureDocsModule` and be included in app/desktop roots. |
| No Room for static docs | ✅ PASS | Docs are packaged assets/resources, not database rows. |
| Strings/resources discipline | ✅ PASS | UI labels for the browser/assistant go in `core/resources`. Long-form docs remain markdown files under `docs/`. |
| Flavor safety | ✅ PASS | Gemini bindings live in `google` flavor or Android-specific DI, with `fdroid` no-op fallback. |
| Design standards gate | ✅ REQUIRED | Must be reviewed before UI implementation and screenshot capture. |
**Gate result**: PASS. Proceed with design and implementation.
## Project Structure
### Spec artifacts
```text
specs/003-app-docs-markdown/
├── spec.md
├── data-model.md
├── research.md
├── plan.md
├── tasks.md
├── quickstart.md
├── checklists/
│ └── requirements.md
└── contracts/
├── deep-link-contract.md
├── keyword-index-schema.json
└── ci-workflow-contract.md
```
### Planned source additions
```text
# Authored docs content
/docs/
├── _config.yml
├── index.md
├── _data/
│ └── versions.yml
├── user/
│ ├── onboarding.md
│ ├── connections.md
│ ├── messages-and-channels.md
│ ├── nodes.md
│ ├── node-metrics.md
│ ├── map-and-waypoints.md
│ ├── settings-radio-user.md
│ ├── settings-module-admin.md
│ ├── telemetry-and-sensors.md
│ ├── tak.md
│ ├── mqtt.md
│ ├── discovery.md
│ ├── firmware.md
│ └── desktop.md
└── developer/
├── architecture.md
├── codebase.md
├── adding-a-feature-module.md
├── navigation-and-deep-links.md
├── transport.md
├── persistence.md
├── testing.md
└── contributing.md
# New KMP feature module
/feature/docs/
├── build.gradle.kts
├── src/commonMain/kotlin/org/meshtastic/feature/docs/
│ ├── model/DocModels.kt
│ ├── data/DocBundleLoader.kt
│ ├── data/KeywordSearchEngine.kt
│ ├── ui/DocsBrowserScreen.kt
│ ├── ui/DocsSearchBar.kt
│ ├── ui/DocsPageRouteScreen.kt
│ ├── ui/ChirpyAssistantSheet.kt
│ ├── navigation/DocsNavigation.kt
│ ├── di/FeatureDocsModule.kt
│ └── ai/AIDocAssistant.kt
├── src/androidMain/kotlin/org/meshtastic/feature/docs/
│ ├── ui/DocHtmlView.android.kt
│ └── ai/AndroidDocsPlatformCapabilities.kt
├── src/jvmMain/kotlin/org/meshtastic/feature/docs/
│ └── ui/DocPageRenderer.jvm.kt
├── src/iosMain/kotlin/org/meshtastic/feature/docs/
│ └── ui/DocPageRenderer.ios.kt
└── src/commonTest/kotlin/org/meshtastic/feature/docs/
├── DocBundleLoaderTest.kt
├── KeywordSearchEngineTest.kt
└── DocsNavigationTest.kt
# Host / flavor integration
/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/
├── Routes.kt
└── DeepLinkRouter.kt
/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/
└── SettingsNavigation.kt
/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt
/app/src/google/kotlin/org/meshtastic/app/di/FlavorModule.kt
/app/src/google/kotlin/org/meshtastic/app/docs/GoogleDocsAiModule.kt
/app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt
/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
# Build logic and workflows
/build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt
/.github/workflows/docs-deploy.yml
/.github/workflows/docs-release.yml
```
## Module Design
### `feature/docs/` responsibilities
**commonMain**
- Data model (`DocPage`, `DocBundle`, `KeywordIndexEntry`)
- Bundle loading and metadata validation
- Search normalization and ranking
- TOC UI, grouped list UI, loading states, error states
- Shared assistant UI state and Chirpy chat presentation
- Navigation entry registration for `SettingsRoute.HelpDocs` and `SettingsRoute.HelpDocPage`
**androidMain**
- `WebView` container via `AndroidView`
- Platform capability checks for WebView and AI
- Optional Android-only page rendering helpers
**jvmMain / iosMain**
- Renderer implementation backed by Compose markdown or embedded browser abstraction
- No Android framework dependencies
### AI boundary
The feature module should not hard-code Google-only APIs in shared code. Instead:
```kotlin
interface AIDocAssistant {
suspend fun answer(question: String): AIDocAssistantResult
}
```
Bindings:
- `google` flavor Android: Gemini Nano implementation
- `fdroid` flavor Android: keyword-search fallback implementation
- Desktop/iOS: keyword-search fallback implementation
This keeps `feature/docs` KMP-friendly and protects the `fdroid` flavor from proprietary dependencies.
> **Cross-spec note (F3):** Feature 001 (Local Mesh Discovery) defines a parallel `DiscoveryRecommendationEngine` interface with the same platform-gating and fallback pattern. The two abstractions are intentionally separate because their prompts, result types, and domain contexts differ significantly. If a third AI-powered feature is added, a shared `core:ai` capability-check and session-factory module should be extracted to avoid further duplication.
## Build Pipeline Plan
### Docs generation
Implement Gradle tasks in build logic rather than shell scripts.
Recommended task layout:
```text
generateDocsBundle # generate packaged docs artifacts + index + CSS sync
validateDocsBundle # schema, missing assets, bundle size, ordering
recordDocsScreenshots # preferred Roborazzi/Paparazzi capture task
publishDocsSite # generate _site/ tree for Pages deployment
```
Possible internal task breakdown:
- parse `docs/**/*.md`
- extract frontmatter (`title`, `nav_order`, `section`, optional aliases)
- strip unsupported frontmatter/attribute lines before HTML conversion
- render HTML with `flexmark-java`
- inject shared CSS and page-level `data-page` marker
- generate `index.json`
- sync bundled artifacts into generated common resources and Android assets
- enforce the 8 MB warning / 10 MB hard limit
### Asset bundling strategy
**Canonical source**
- Markdown: `/docs/**`
- Shared styles: generated + checked-in CSS
- Screenshots: generated or curated PNGs referenced by markdown
**Generated outputs**
- `feature/docs/build/generated/docs/common/` → packaged into shared resources
- `feature/docs/build/generated/docs/androidAssets/` → optional Android WebView mirror
- `_site/` → Pages deployment artifact
**Reasoning**
- Avoid committing generated HTML into source directories.
- Keep the same content pipeline for both website and in-app docs.
- Support Android WebView without forcing every target to use assets.
## Navigation Plan
### Route additions
Add to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`:
```kotlin
@Serializable data object HelpDocs : SettingsRoute
@Serializable data class HelpDocPage(val pageId: String) : SettingsRoute
```
### Deep links
Extend `DeepLinkRouter` so the following resolve into typed routes:
- `meshtastic://meshtastic/settings/helpDocs`
- `meshtastic://meshtastic/settings/helpDocs/{pageId}`
- optional canonical alias `meshtastic://meshtastic/settings/help-docs`
### Settings integration
Update `feature/settings/.../SettingsNavigation.kt` so Help & Documentation appears as a new Settings row and the docs routes are registered as part of the same Settings graph.
## UI Plan
### Docs browser
- Sectioned TOC grouped into User Guide / Developer Guide
- Search bar filtering by title, aliases, and keywords
- Loading, empty-state, and missing-page surfaces
- Responsive layout that works in compact phones and larger Desktop/iPad form factors
### Chirpy assistant
- Shared Compose chat UI with `LazyColumn`
- Right-aligned user messages, left-aligned Chirpy messages
- Pinned bottom input bar with `imePadding()`
- Scroll-to-dismiss-keyboard behavior
- Source chips or page links beneath generated answers
- Hidden entirely when the bound assistant implementation reports unsupported
### Styling
- UI labels and button text go into `core/resources/.../strings.xml`
- Long-form docs stay in markdown under `/docs/`
- Use `MeshtasticIcons` for help/search/info/security references inside app UI
- Chirpy avatar shipped as vector/SVG compatible asset
## AI Integration Plan
### Supported environments
**Primary target**
- Android 14+
- `google` flavor
- Runtime Gemini Nano/AICore availability
**Fallback targets**
- Android `fdroid`
- Android devices without supported model/runtime
- Desktop
- iOS
### Flow
1. User submits a question.
2. Shared search engine normalizes and scores pages from `index.json`.
3. Top-ranked pages are selected within the token budget.
4. Android Google implementation calls Gemini Nano on-device.
5. If unavailable or busy, return fallback result with suggested pages.
### Safety/UX guardrails
- Never send source code or private mesh data to the model.
- Never show the AI input when the implementation is unsupported.
- Always include the supporting page links used for the answer.
- Respect runtime quota/battery/busy errors with clear user messaging.
## Testing Plan
### Unit / common tests
- `DocBundleLoaderTest`: index decoding, page ordering, missing assets, size metadata
- `KeywordSearchEngineTest`: scoring, aliases, tie-breaking, token budgeting
- `DocsNavigationTest`: route serialization, deep-link mapping, page navigation
### Android-specific tests
- WebView surface smoke test or rendering wrapper test
- AI capability gate tests
- Screenshot tests for docs browser TOC and state/icon reference assets
### Integration validation
- `./gradlew :feature:docs:detekt :feature:docs:allTests`
- `./gradlew kmpSmokeCompile`
- docs-specific workflow dry run (site build + schema validation)
## CI / Release Plan
### Continuous beta
- Trigger: push to `main`
- Runner: `ubuntu-24.04`
- Steps:
1. checkout + submodules
2. JDK 21 + Gradle setup
3. `./gradlew generateDocsBundle validateDocsBundle publishDocsSite -Pdocs.channel=beta -Pci=true`
4. run `recordDocsScreenshots` or screenshot validation task
5. compare screenshot asset diff and open bot PR if changed
6. upload Pages artifact and deploy `/beta/`
### Release publish
- Trigger: tag `v*.*.*`
- Adds version manifest update, `/latest/` redirect refresh, and stable site publish to `/vX.Y.Z/`
## Risks and Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| Gemini Nano API or device support changes | Medium | Hide AI behind interface + runtime gating; keep keyword fallback first-class |
| WebView/resource packaging is awkward on Android | Medium | Mirror generated HTML into Android assets if direct resource loading is unreliable |
| Screenshot automation adds too much CI cost | Medium | Prefer Roborazzi on Ubuntu and keep validation-only fallback mode documented |
| Docs bundle exceeds size budget | Medium | Enforce size checks and trim screenshots before adding more features |
| Desktop/iOS rendering parity diverges from Android HTML | Medium | Keep `DocPageContent` capable of serving both HTML and markdown and test representative pages on all targets |
## Implementation Phases
1. **Phase 0 Design standards gate**: review design standards, confirm docs/browser/chat visuals.
2. **Phase 1 Content authoring**: write user and developer markdown, collect screenshots.
3. **Phase 2 Web site setup**: Jekyll config, versioning, Pages scaffolding.
4. **Phase 3 Build pipeline**: Gradle tasks, markdown conversion, bundle validation, generated resources.
5. **Phase 4 In-app browser**: feature module, navigation, TOC, page rendering.
6. **Phase 5 Search/index**: keyword index, search bar, result ranking.
7. **Phase 6 AI assistant**: Android Google flavor Gemini implementation + fallbacks.
8. **Phase 7 CI automation**: deploy/release workflows, screenshot PR bot.
9. **Phase 8 Polish**: accessibility, dark mode, edge cases, documentation cleanup.
## Validation Matrix
| Change Area | Minimum Validation |
|-------------|--------------------|
| Docs markdown / build pipeline | `./gradlew generateDocsBundle validateDocsBundle` |
| Shared docs feature logic | `./gradlew :feature:docs:allTests` |
| Android rendering integration | `./gradlew :feature:docs:compileKotlinJvm kmpSmokeCompile` plus Android host tests if added |
| CI/workflow updates | Dry-run logic locally where possible, then validate YAML structure and contract |
| Final feature branch verification | `./gradlew spotlessCheck detekt kmpSmokeCompile test allTests` plus docs generation tasks |
## Definition of Done
- `feature/docs/` exists and is wired into settings navigation and DI.
- Website docs build and deploy from the same source corpus used in-app.
- Deep links open docs home and specific pages through typed routes.
- Search works on all platforms.
- Gemini Nano assistant works on supported Android Google devices and gracefully falls back everywhere else.
- CI enforces schema, size, and deployment correctness.
- Documentation and assets remain under the configured bundle-size ceiling.

View File

@@ -0,0 +1,156 @@
# Quickstart: App Documentation (Android/KMP)
## Prerequisites
- JDK 21
- Android SDK (`ANDROID_HOME` set or discoverable)
- Git submodules initialized
- `local.properties` present (`cp secrets.defaults.properties local.properties` if needed)
- Ruby + Bundler only if you want to preview Jekyll locally
- No Node.js requirement
## 1. Bootstrap the workspace
```bash
git submodule update --init
[ -f local.properties ] || cp secrets.defaults.properties local.properties
```
## 2. Author or edit docs content
Markdown source lives in:
- `docs/user/*.md`
- `docs/developer/*.md`
Example frontmatter:
```markdown
---
title: Messages & Channels
nav_order: 3
aliases:
- channels
- direct-messages
---
# Messages & Channels
```
## 3. Generate the bundled docs corpus
```bash
./gradlew generateDocsBundle validateDocsBundle
```
Expected outputs:
- generated HTML for Android/Web docs parity
- optional markdown mirror for Compose renderers
- `index.json` keyword index
- shared CSS and callout styling
- size/schema/asset validation
## 4. Build the GitHub Pages site artifact locally
```bash
./gradlew publishDocsSite -Pdocs.channel=beta
```
This should produce a deployable `_site/` tree with `/beta/` output.
## 5. Refresh screenshot assets
Preferred path if Roborazzi is used:
```bash
./gradlew recordDocsScreenshots
```
If the project adopts Paparazzi instead, run the equivalent Paparazzi record task defined by the implementation.
## 6. Run docs-specific tests
```bash
./gradlew :feature:docs:allTests :feature:docs:detekt
./gradlew kmpSmokeCompile
```
## 7. Run full repo verification before shipping
```bash
./gradlew spotlessCheck detekt assembleDebug test allTests generateDocsBundle validateDocsBundle
```
## 8. Preview the Jekyll site locally (optional)
```bash
cd docs
bundle exec jekyll serve --livereload
# open http://127.0.0.1:4000
```
Recommended gems:
```bash
gem install bundler jekyll just-the-docs jekyll-redirect-from
```
## 9. Test the in-app route
Open the app and navigate to:
- **Settings → Help & Documentation**
Deep link contract:
```text
meshtastic://meshtastic/settings/helpDocs
```
Optional specific-page form:
```text
meshtastic://meshtastic/settings/helpDocs/messages-and-channels
```
## 10. Verify target-specific behavior
### Android
- Docs open in a WebView-backed page renderer
- `google` flavor on supported Android 14+ devices may show Chirpy AI
- `fdroid` flavor must still show keyword search and docs pages
### Desktop / iOS
- Docs open through the shared renderer abstraction
- Keyword search works even when AI is unsupported
## Key file locations
| Path | Purpose |
|------|---------|
| `docs/` | Authored markdown content and site config |
| `feature/docs/` | New KMP feature module for in-app docs |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` | Typed docs routes |
| `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` | Deep-link mapping |
| `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` | Settings entry point |
| `build-logic/convention/.../DocsTasks.kt` | Gradle docs pipeline |
| `.github/workflows/docs-deploy.yml` | Continuous beta docs deploy |
| `.github/workflows/docs-release.yml` | Versioned release deploy |
## Troubleshooting
**`generateDocsBundle` fails on markdown parsing**
Check frontmatter syntax, unsupported attribute lines, and table formatting.
**`validateDocsBundle` reports missing assets**
Confirm every referenced screenshot exists in the generated or curated asset set.
**AI assistant not visible on Android**
Check device support, Android version, flavor (`google` only), and runtime model availability.
**Desktop/iOS page looks different from Android**
Confirm whether the renderer is using HTML or markdown mode and compare against the generated HTML output.
**Bundle size exceeds 10 MB**
Trim screenshots, compress assets, or reduce duplicated artifacts before raising the limit.

View File

@@ -0,0 +1,158 @@
# Research: App Documentation (Android/KMP)
## R-001: CI/CD pipeline architecture for docs generation and deployment
**Decision**: Use GitHub Actions on `ubuntu-24.04` with Gradle-driven docs generation and deployment. Split the workflow into a docs-build path and an optional screenshot path inside the same workflow. Prefer `Roborazzi` for screenshot automation because it fits the repositorys Gradle-heavy Ubuntu CI and does not require a macOS runner.
**Rationale**:
- Meshtastic-Android already runs its main CI on `ubuntu-24.04` for Gradle-heavy jobs. Reusing that environment aligns with the current runner strategy in `.github/workflows/reusable-check.yml`.
- Markdown conversion can run entirely inside Gradle with JDK 21, requiring no platform-specific build tools.
- Roborazzi integrates better with Compose and Gradle pipelines than a custom screenshot export shell flow. Paparazzi remains acceptable if the UI under test ends up being Android-only.
**Alternatives considered**:
- **macOS runner**: Rejected for the default path because it is slower, more expensive, and unnecessary for Gradle docs generation.
- **Native GitHub Pages Jekyll build**: Rejected because we need build-time preprocessing, schema validation, asset copying, and bundle-size enforcement.
- **Paparazzi as the default**: Considered. Deferred in favor of Roborazzi because the project already leans on KMP and Gradle-first tooling, while Paparazzi is more Android-view-centric.
**Implementation direction**:
- `docs-deploy.yml`: push to `main` → generate `/beta/`, validate assets, deploy Pages.
- `docs-release.yml`: tag `v*.*.*` → generate `/vX.Y.Z/`, update versions manifest, update `/latest/`, deploy Pages.
- Both workflows use JDK 21, existing Gradle setup helpers, and explicit Gradle task paths.
---
## R-002: Version selector strategy for release-specific docs
**Decision**: Keep the web documentation on Jekyll + GitHub Pages using a `just-the-docs` version selector backed by `_data/versions.yml`.
**Rationale**:
- The versioning problem is platform-independent and maps cleanly to this project's release cadence.
- `just-the-docs` gives us sidebar navigation, search, and version-switch UI without building a custom docs frontend.
- The in-app bundle remains pinned to the shipped app version, while the web site can expose stable and beta versions at the same time.
**Alternatives considered**:
- **Always show latest docs**: Rejected. The app ships multiple versions in the wild and docs must track app releases, not only firmware.
- **Separate repos or branches per release**: Rejected due to maintenance overhead and reduced traceability.
- **Custom static site frontend**: Rejected for unnecessary complexity.
**Implementation direction**:
- `docs/index.md` redirects to `/latest/`.
- `docs/_data/versions.yml` is updated by the release workflow.
- Beta builds publish `/beta/` with a visible pre-release banner.
---
## R-003: Markdown rendering strategy across Android, Desktop, and iOS
**Decision**: Use `flexmark-java` in Gradle for canonical markdown-to-HTML conversion. Package generated HTML for Android WebView parity and optionally package a markdown mirror so Desktop/iOS can use an existing Compose markdown renderer when that provides a better UX than an embedded browser.
**Rationale**:
- The repository already includes `com.mikepenz:multiplatform-markdown-renderer` in `gradle/libs.versions.toml`, so Desktop/iOS can reuse an existing CMP-oriented path.
- `flexmark-java` provides better GitHub-Flavored Markdown support than bare `commonmark-java`, including tables, task lists, and richer extension handling.
- Android WebView is the safest way to preserve exact site parity for complex tables, callouts, and responsive screenshots.
**Alternatives considered**:
- **`commonmark-java`**: Viable, but weaker out-of-the-box GFM coverage.
- **Compose-only rendering on all targets**: Rejected as the primary path because HTML/site parity is more important on Android, and not all advanced formatting maps cleanly to Compose text rendering.
- **Platform-specific web surfaces on every target**: Considered, but a Compose markdown renderer remains attractive for Desktop/iOS if it avoids platform web wrappers.
**Implementation direction**:
- Gradle task parses markdown, strips frontmatter, and generates HTML + index metadata.
- Android reads HTML through WebView.
- Desktop/iOS use `DocPageContent` and a renderer abstraction that can choose HTML or markdown at runtime.
---
## R-004: Keyword index JSON structure and search strategy
**Decision**: Generate a compact JSON array of `KeywordIndexEntry` objects containing `id`, `title`, `section`, `resourcePath`, `navOrder`, `keywords`, `aliases`, and `charCount`.
**Rationale**:
- The same index can power three different use cases: TOC ordering, keyword search, and AI retrieval.
- Including `navOrder` prevents the in-app browser from drifting away from authored reading order.
- Including `resourcePath` avoids recomputing target-specific asset paths at runtime.
**Alternatives considered**:
- **Full-text search index (Lunr-style)**: Rejected as overkill for the initial corpus size.
- **Vector embeddings**: Rejected because they add model complexity, larger assets, and more opaque retrieval behavior.
- **Store entire page text in the index**: Rejected because it duplicates the actual docs corpus and inflates bundle size.
**Implementation direction**:
- Keywords are generated from normalized headings, emphasized terms, and curated aliases.
- Search ranks title matches above keyword matches.
- AI retrieval consumes only top-ranked pages within the token budget.
---
## R-005: Android WebView and cross-platform rendering surface
**Decision**: On Android, wrap `WebView` with `AndroidView` in `androidMain` and load packaged HTML via local assets/resources. On Desktop/iOS, define a small `DocRenderSurface` abstraction so the module can choose either an embedded browser or Compose markdown renderer without changing the shared search/navigation logic.
**Rationale**:
- Android WebView is a mature, well-understood path for local HTML and complex CSS.
- Shared search state, bundle loading, and route handling belong in `commonMain`; only the final rendering surface needs to vary by platform.
- A renderer abstraction keeps the docs feature compatible with the project rule of preferring interfaces + DI over heavy `expect`/`actual` usage for non-trivial capabilities.
**Alternatives considered**:
- **Pure `expect`/`actual` screen per platform**: Rejected because it duplicates too much UI state and search logic.
- **Android-only docs feature**: Rejected because the project is KMP and the docs corpus is valuable on Desktop and iOS too.
- **Single Compose markdown renderer everywhere**: Rejected as the default because Android WebView gives better fidelity for generated HTML and web-site parity.
**Implementation direction**:
- `commonMain`: `DocBrowserScreen`, search state, TOC, models, bundle loader.
- `androidMain`: `DocHtmlView.android.kt`, optional Gemini Nano engine hooks.
- `jvmMain` / `iosMain`: renderer-specific page surface.
---
## R-006: Asset bundling strategy with Gradle resources and Android assets
**Decision**: Generate docs artifacts into `feature/docs/build/generated/...` and wire them into the module through Gradle source directories. Keep a shared common-resources path for KMP targets and optionally mirror generated HTML into Android assets for WebView file loading.
**Rationale**:
- Gradle must own the bundling pipeline for all targets.
- Generated outputs should not be committed into `src/` just to satisfy packaging.
- Android WebView sometimes benefits from asset-backed file URLs, while Desktop/iOS can read from common packaged resources.
**Alternatives considered**:
- **Commit generated HTML into source directories**: Rejected because it mixes authored and generated artifacts and encourages drift.
- **Bundle only markdown and render everything at runtime**: Rejected because it increases runtime complexity and risks inconsistent output.
- **Bundle only HTML and no markdown mirror**: Considered. Accepted for Android, but a markdown mirror remains useful for Desktop/iOS Compose rendering.
**Implementation direction**:
- Canonical source: `docs/**/*.md`.
- Generated output: `feature/docs/build/generated/docs/{common,android-site}`.
- Gradle registers generated resource directories lazily with provider APIs.
- Android asset mirror is produced only if the WebView implementation requires it.
---
## R-007: Navigation integration with SettingsGraph and typed routes
**Decision**: Add docs routes to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` as part of `SettingsRoute`, and register docs entries from `feature/docs` into the Settings graph. Deep links are resolved by `DeepLinkRouter` into typed docs routes, not raw strings.
**Rationale**:
- The repository already centralizes routes in `Routes.kt` and uses typed `@Serializable sealed interface` navigation.
- The docs feature is conceptually a Settings subsection, not a new top-level destination.
- Keeping docs routes typed ensures deep-link tests, saved state, and backstack behavior stay consistent with the rest of the app.
**Alternatives considered**:
- **Standalone `DocsRoute` graph**: Considered, but rejected for the first iteration because the entry point still belongs to Settings and would require extra indirection.
- **Raw string route names inside the feature module**: Rejected because it breaks the repositorys Navigation 3 convention.
- **App-only docs route wiring**: Rejected because Desktop also needs the route in shared navigation.
**Implementation direction**:
- Add `SettingsRoute.HelpDocs` and `SettingsRoute.HelpDocPage(val pageId: String)`.
- Extend `DeepLinkRouter.settingsSubRoutes` with `help-docs` and `helpDocs` aliases.
- Add docs navigation entries from `feature/docs` and wire them into `feature/settings` flow composition.
---
## Summary of chosen approach
1. **Web docs** stay on Jekyll + GitHub Pages.
2. **Build pipeline** moves into Gradle using `flexmark-java` and generated resources.
3. **In-app browser** becomes a new KMP feature module under `feature/docs/`.
4. **Android rendering** uses WebView; **Desktop/iOS** use a renderer abstraction.
5. **AI** is Android-only, on-device, and flavor-gated with keyword-search fallback everywhere else.
6. **CI** remains Ubuntu/Gradle-first and adds screenshot automation only through Android-friendly tooling.

View File

@@ -0,0 +1,204 @@
# Feature Specification: App Documentation (Jekyll Site + In-App Docs + Gemini Nano Assistant)
**Feature Branch**: `003-app-docs-markdown`
**Created**: 2026-05-07
**Status**: Not Started
**Input**: User description: "Complete markdown documentation for the Meshtastic Android app — served as a GitHub Pages Jekyll site, bundled in-app for offline browsing and Gemini Nano Q&A, with GitHub Actions CI auto-regeneration on push to main. Covers both end users and developers."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - End User Reads Docs on the Web (Priority: P1)
A new Meshtastic user visits the GitHub Pages documentation site to learn how to pair a radio, send messages, understand node status, and troubleshoot common tasks. They can browse a structured documentation site with screenshots, plain-language explanations, and release-specific versions.
**Why this priority**: This delivers immediate value to the broadest audience, is platform-independent, and can ship before any in-app UI work.
**Independent Test**: Navigate the deployed GitHub Pages site, open "Getting Started", complete the pairing flow, then move to "Messages & Channels" and "Nodes" using the sidebar without using source code or the app.
**Acceptance Scenarios**:
1. **Given** a user opens the docs site root, **When** they choose "Getting Started", **Then** they see a step-by-step guide to first-run setup with screenshots or annotated illustrations for each major step.
2. **Given** a user is on any page, **When** they use the sidebar or version selector, **Then** they can reach another page in two interactions or fewer.
3. **Given** a user is reading a feature page such as "Messages & Channels", **Then** they see at least one inline screenshot and a plain-language explanation of the main visible controls, indicators, and states.
---
### User Story 2 - End User Browses Docs Inside the App (Priority: P2)
A user already using the app opens **Settings → Help & Documentation** and browses the same content offline. Android devices render bundled HTML inside a WebView, while Desktop and iOS use a shared embedded browser abstraction or Compose markdown renderer backed by the same bundled content.
**Why this priority**: It meets users in context, works offline, and turns documentation into part of the product instead of an external destination.
**Independent Test**: Put the device in airplane mode, open **Settings → Help & Documentation**, search for "channel", open the matching page, and read the full article with inline images.
**Acceptance Scenarios**:
1. **Given** the app is installed, **When** the user opens Help & Documentation, **Then** a table of contents with User Guide and Developer Guide sections appears within 1 second.
2. **Given** a page contains images or tables, **When** the page renders on Android, Desktop, or iOS, **Then** screenshots and formatting appear inline without network access.
3. **Given** the device is offline, **When** the user opens any bundled page, **Then** content loads successfully from packaged resources or assets.
---
### User Story 3 - End User Asks the App a Question with AI (Priority: P3)
A user on a supported Android device types "How do I set a channel password?" into the in-app assistant. The app retrieves relevant doc pages from the bundled corpus and answers using Gemini Nano running on-device. On unsupported Android devices, `fdroid`, Desktop, and iOS, the app falls back to keyword search and suggested articles.
**Why this priority**: It is high-value and differentiating, but depends on the documentation corpus and in-app browser already existing.
**Independent Test**: On a supported Android 14+ device in the `google` flavor, open Help & Documentation, ask "How do I add a waypoint?", and receive an on-device answer or a clear keyword-search fallback if the model is unavailable.
**Acceptance Scenarios**:
1. **Given** a supported Android 14+ device with Gemini Nano available, **When** the user submits a question, **Then** a response appears within 5 seconds using only bundled documentation as context.
2. **Given** an unsupported device, unsupported flavor, Desktop, or iOS, **When** the user opens Help & Documentation, **Then** they see keyword search and suggested pages with no broken AI controls.
3. **Given** the model is busy, unavailable, or the question is outside the documentation scope, **When** the assistant responds, **Then** it acknowledges the limitation and offers direct links to relevant doc pages instead of hallucinating.
---
### User Story 4 - Developer Reads Architecture Docs (Priority: P4)
A contributor opens the Developer Guide to understand the KMP architecture, Navigation 3 patterns, DI wiring, transport layers, testing approach, and how to add a new feature module.
**Why this priority**: It improves contributor onboarding and reduces tribal knowledge, but is less urgent than end-user documentation.
**Independent Test**: A contributor unfamiliar with Meshtastic-Android reads the Architecture and Codebase docs and can explain the path from a `Routes.kt` NavKey to a screen and from transport events to shared state.
**Acceptance Scenarios**:
1. **Given** a developer visits the Developer Guide, **When** they browse the section, **Then** they see separate pages for Architecture, Codebase Structure, Adding a Feature, Transport, Persistence, Testing, and Contributing.
2. **Given** a developer reads any architecture page, **Then** key modules, routes, and responsibilities are described without requiring prior repository knowledge.
---
### User Story 5 - Docs Stay Current Automatically (Priority: P5)
When documentation-relevant UI or workflow changes merge to `main`, GitHub Actions rebuilds the docs site, refreshes screenshot assets when the configured screenshot task is enabled, validates the packaged bundle, and republishes the beta docs. Release tags publish immutable versioned docs.
**Why this priority**: Without automation, docs drift quickly and lose trust.
**Independent Test**: Merge a PR that changes a user-facing settings label. The docs workflow rebuilds the affected page, validates or regenerates screenshots, updates the beta site, and opens a bot PR if screenshot assets changed.
**Acceptance Scenarios**:
1. **Given** a push to `main`, **When** the docs workflow runs, **Then** it rebuilds the site, validates the index and bundle size, and refreshes screenshot assets using the configured Android screenshot pipeline.
2. **Given** a release tag `vX.Y.Z`, **When** the release workflow completes, **Then** a versioned `/vX.Y.Z/` docs snapshot is published and the version selector is updated.
3. **Given** the docs build, screenshot validation, or schema validation fails, **When** the workflow completes, **Then** deployment is blocked and the failure is reported clearly.
---
### Edge Cases
- What if Gemini Nano or AICore becomes unavailable mid-session? Show a fallback message and surface the top keyword-matched pages.
- What if the active flavor is `fdroid` and the AI stack is unavailable by design? Show the same browser and search UI with no dead-end affordances.
- What if a screenshot file referenced by markdown is missing? Render the page without the image, log the problem in validation, and fail CI before deployment.
- What if the user opens a deep link to a page slug removed in a later version? Open the docs home screen and surface a "page not found" message with suggested pages.
- What if the user asks a question in a language different from the English source docs? Answer in the users language when the runtime supports it; otherwise fall back to English plus suggested articles.
- What if the screenshot framework is temporarily disabled or unavailable in CI? The workflow may run in validation-only mode for screenshots, but it must still fail on missing referenced assets and must report that automation is degraded.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The documentation system MUST produce markdown source files organised into at least two top-level sections: **User Guide** and **Developer Guide**.
- **FR-002**: Every User Guide page MUST include at least one screenshot or annotated illustration where a corresponding asset exists for the documented feature.
- **FR-003**: The Jekyll site MUST be built and deployed by GitHub Actions using `actions/deploy-pages`. All markdown conversion, asset validation, version-path assembly, and size enforcement MUST happen before the Pages deployment step. Native GitHub Pages Jekyll build MUST be disabled via `.nojekyll` in the published output.
- **FR-004**: `docs/_config.yml` MUST configure Jekyll with `just-the-docs` or an equivalent minimal theme that supports sidebar navigation, search, and a hierarchy matching the doc section structure.
- **FR-005**: All authored documentation source MUST live under the repository-root `docs/` directory so that content changes are reviewed alongside app changes.
- **FR-006**: The app MUST expose **Help & Documentation** inside the Settings flow. The feature MUST live inside the existing Navigation 3 settings graph and MUST NOT introduce a new top-level tab or destination group.
- **FR-007**: The in-app doc browser MUST render GitHub Flavored Markdown content offline. Android MUST load generated bundled HTML through `WebView` via `AndroidView`. Desktop and iOS MUST render the same bundled content through either an embedded browser surface or a shared Compose markdown renderer.
- **FR-008**: Screenshot assets used by docs MUST come from checked-in documentation captures, Android screenshot automation (`Roborazzi` preferred, `Paparazzi` acceptable), or equivalent generated PNG assets, and MUST be copied into the bundled docs output during the build pipeline.
- **FR-009**: On supported Android 14+ devices running the `google` flavor, Help & Documentation MUST expose free-text Q&A powered by Gemini Nano running on-device through Google AI Edge SDK (the canonical API across the project; see also spec 001).
- **FR-010**: On unsupported Android devices, unsupported flavors, Desktop, and iOS, Help & Documentation MUST show the standard doc browser with keyword search and suggested pages only. No broken AI controls or loading indicators may be shown.
- **FR-011**: The AI integration MUST use a pre-built keyword index JSON generated at build time to retrieve the top 23 most relevant pages for a given question. The combined prompt context passed to Gemini Nano MUST NOT exceed 3,000 estimated tokens or the runtime-reported safe prompt limit, whichever is lower. Only bundled documentation text MAY be passed as context.
- **FR-012**: A GitHub Actions workflow MUST trigger on every push to `main` and: (a) build the docs site, (b) run the configured screenshot generation or validation task, (c) sync screenshot assets into the docs bundle, and (d) deploy the beta docs site.
- **FR-013**: If CI detects screenshot asset changes, it MUST open an automated PR from a bot branch targeting `main`. It MUST NOT push screenshot changes directly to `main`.
- **FR-014**: The initial User Guide MUST cover at minimum these 14 feature areas: Onboarding & First Launch, Bluetooth / USB / TCP Device Connection, Messages & Channels, Nodes List, Node Detail & Metrics, Map & Waypoints, Settings Radio & User, Settings Module & Administration, Telemetry & Sensors, TAK Integration, MQTT, Local Mesh Discovery, Firmware Updates, and Desktop App / Cross-Platform Hosts.
- **FR-015**: The initial Developer Guide MUST cover at minimum: Architecture Overview, Codebase Structure, Adding a New Feature Module, Navigation 3 & Deep Links, BLE / TCP / Serial Transport, Room KMP & DataStore Architecture, Testing (unit + screenshot), and Contributing & PR Workflow.
- **FR-016**: The markdown-to-HTML build step MUST use a JVM-native parser such as `flexmark-java` (preferred) or `commonmark-java` so it can run inside Gradle on the existing CI infrastructure without adding Node.js.
- **FR-017**: Because Meshtastic-Android has no existing help system, documentation authoring MUST treat current in-app screens, existing user-facing strings, and feature module flows as the authoritative source of truth. At minimum, content mapping MUST be established from:
- `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt`, `BluetoothScreen.kt`, `LocationScreen.kt`, `NotificationsScreen.kt`, `CriticalAlertsScreen.kt` → onboarding, permissions, and connection setup pages.
- `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/ui/contact/Contacts.kt`, `Message.kt`, `component/MessageScreenComponents.kt` → Messages & Channels.
- `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`, `detail/NodeDetailScreens.kt`, and `metrics/*` → Nodes, metrics, and telemetry pages.
- `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt` and `feature/map/src/commonMain/kotlin/org/meshtastic/feature/map/navigation/MapNavigation.kt` → Map & Waypoints.
- `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/SettingsViewModel.kt`, `DeviceConfigurationScreen.kt`, `ModuleConfigurationScreen.kt`, and `navigation/SettingsNavigation.kt` → settings documentation.
- `feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt` → firmware update guidance and warnings.
- **FR-018**: The docs authoring process MUST lift short-form guidance from onboarding screens, warnings, banners, disclaimers, and empty-state messaging into highlighted Tips/Warnings callouts inside the relevant pages.
- **FR-019**: The in-app doc browser MUST use typed Navigation 3 routes declared in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` and registered through the Settings flow. The docs home entry MUST behave like any other `NavKey` destination and support standard back navigation via `NavigationBackHandler`.
- **FR-020**: Documentation versions MUST be tied to app release versions, not firmware versions:
- A git tag push matching `v*.*.*` MUST publish a stable snapshot under `/vX.Y.Z/`.
- Pushes to `main` without a release tag MUST overwrite `/beta/` and visibly mark pages as pre-release.
- The site MUST expose a version selector for stable releases and current beta.
- The bundled in-app docs MUST always match the app version that shipped with them.
- The site root MUST redirect to the latest stable release by default.
- **FR-021**: The complete bundled docs corpus (HTML, markdown resources if retained, index JSON, CSS, SVGs, and screenshots) MUST NOT exceed 10 MB at build time. The build MUST warn at 8 MB and fail at 10 MB.
- **FR-022**: Generated docs styling MUST support light and dark themes. CSS MUST define theme variables for background, text, links, code blocks, and callouts. Android `WebView` surfaces and non-Android renderer surfaces MUST align with Material 3 background colors to avoid white flashes during load.
- **FR-023**: Bundled docs MUST be delivered through Gradle-managed resources. Shared docs assets MUST be available to KMP targets through common resources, and Android MUST additionally support an asset-based mirror if local `WebView` file access requires `AssetManager` paths.
- **FR-024**: Help & Documentation MUST be addressable by a deep link that resolves to the Settings graph. The canonical deep link slug MUST be lowercase-hyphenated (`meshtastic://meshtastic/settings/help-docs`), consistent with the project-wide deep link convention established in spec 001. A camelCase compatibility alias (`helpDocs`) MUST also be accepted for backward compatibility.
- **FR-025**: The Gradle docs-generation task MUST strip YAML frontmatter and unsupported attribute lines before HTML conversion so they never appear as literal content in generated pages.
- **FR-026**: The build pipeline MUST post-process markdown output so Tip and Warning blockquotes become styled callouts, and beta builds inject a reusable pre-release banner class instead of inline styles.
- **FR-027**: The shared docs stylesheet MUST define `.tips-callout`, `.warning-callout`, and `.pre-release-banner` using CSS custom properties so those surfaces adapt cleanly in light and dark mode.
- **FR-028**: Each generated `KeywordIndexEntry` MUST include a `navOrder` integer sourced from frontmatter. The in-app bundle loader MUST sort pages by `navOrder` within each section instead of sorting alphabetically.
- **FR-029**: The AI assistant MUST be branded as **Chirpy** and presented as a chat UI, not a plain form. The UI MUST use a scrollable message list, right-aligned user bubbles, left-aligned Chirpy bubbles, a pinned input bar, session message history, and keyboard-dismiss-on-scroll behavior appropriate to Compose.
- **FR-030**: Any User Guide page with matching screenshot assets MUST embed those assets inline using normal markdown image syntax or generated equivalent HTML. Images MUST be responsive and visually bounded with radius and spacing rules.
- **FR-031**: The screenshot strategy MUST include dedicated icon/state coverage for connection status, security/lock indicators, node status, and at least one docs-browser rendering case. The generated assets MUST be copied into the docs bundle and referenced from the appropriate pages.
- **FR-032**: Any page showing two or more small icon or state screenshots as standalone blocks MUST instead convert them into reference tables with at least Icon and Description columns. Named states or roles MUST use a 3-column Icon / Name / Description table.
- **FR-033**: Icon-level screenshot tests SHOULD use transparent PNG output when the selected screenshot framework supports alpha output, and they MUST render plain icon composables rather than interactive wrappers so background artifacts do not leak into docs assets.
- **FR-034**: The generated HTML template MUST emit a stable page identifier (for example `data-page="messages"`) so page-specific CSS overrides remain possible while keeping a shared global icon/table baseline.
- **FR-035**: Chirpy branding MUST use a shared SVG or vector drawable sourced from the Meshtastic design system and bundled as a scalable asset that renders crisply across Android, Desktop, and iOS.
- **FR-036**: Connection-state icon captures and inline docs illustrations MUST use `MeshtasticIcons` equivalents and Material 3 semantic colors so they remain legible in both light and dark themes without relying on ad-hoc color inversion.
- **FR-037**: Lock and security icon captures using `MeshtasticIcons.Lock`, `MeshtasticIcons.LockOpen`, `MeshtasticIcons.KeyOff`, or their final equivalents MUST preserve portrait aspect ratio at the shared 44dp reference height and MUST avoid canvas squashing in generated docs assets.
### Key Entities
- **DocPage**: A single documentation topic with a stable slug, title, section, nav order, resource path, keywords, and token budget metadata.
- **DocSection**: A top-level grouping such as User Guide or Developer Guide that determines sidebar and in-app TOC hierarchy.
- **KeywordIndexEntry**: A generated JSON descriptor used by both keyword search and Gemini Nano retrieval.
- **DocBundle**: The runtime view of all packaged docs, screenshots, CSS, and metadata available offline.
- **AIDocAssistant**: The shared docs-assistant abstraction that provides on-device answers on supported Android devices and fallback search elsewhere.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: All 14 feature areas in FR-014 have a published User Guide page in the initial release.
- **SC-002**: All Developer Guide topics in FR-015 have a published page in the initial release.
- **SC-003**: The in-app docs browser displays a table of contents within 1 second on supported devices.
- **SC-004**: A user can locate the answer to "How do I connect a device?" in 3 steps or fewer from the in-app Help entry point.
- **SC-005**: The beta docs CI workflow rebuilds and republishes documentation within 10 minutes of a push to `main`.
- **SC-006**: On supported Gemini Nano devices, the assistant responds to a valid documentation question within 5 seconds using only on-device processing.
- **SC-007**: Unsupported devices and flavors still provide keyword search and suggested pages with no broken AI affordances.
- **SC-008**: The published docs home page scores 90+ on Lighthouse accessibility.
- **SC-009**: Every User Guide page with matching screenshot assets embeds at least one inline image sourced from the bundled docs asset set.
- **SC-010**: A Desktop App / Cross-Platform Hosts page exists and documents Desktop-specific entry points, transport options, and parity notes.
- **SC-011**: Pages that document icon-heavy states use reference tables instead of repeated standalone icon blocks.
- **SC-012**: Icon/state screenshot coverage exists for connection state, security/lock state, node status, and at least one docs-browser rendering case.
- **SC-013**: The Chirpy assistant appears as a chat interface with a bundled vector asset and preserves per-session message history while the browser is open.
- **SC-014**: Connection and security icon assets remain legible in light and dark modes and preserve expected aspect ratio in generated docs output.
## Clarifications
### Session 2026-05-07
- Q: Where should the feature live in the codebase? → A: In a new KMP feature module at `feature/docs/` using the `meshtastic.kmp.feature` plugin.
- Q: Where does the in-app entry point belong? → A: Inside the existing settings flow, routed as a typed Navigation 3 destination under `SettingsRoute` and `SettingsGraph`.
- Q: How should in-app docs render across targets? → A: Android uses `WebView` with bundled HTML; Desktop and iOS use a shared embedded browser abstraction or Compose markdown renderer over the same bundled content.
- Q: What converts markdown to bundled HTML? → A: A Gradle task using `flexmark-java` (preferred) or `commonmark-java`.
- Q: How should search and AI retrieval work? → A: A generated keyword index JSON powers both keyword search and Gemini Nano retrieval. Runtime retrieval uses top-ranked pages only.
- Q: Which targets get AI? → A: Supported Android 14+ devices in the `google` flavor when Gemini Nano/AICore is available. `fdroid`, Desktop, and iOS fall back to keyword search.
- Q: How should the feature respect KMP architecture boundaries? → A: Shared models, search, and UI state live in `commonMain`; Android-specific `WebView` and Gemini Nano code live in `androidMain` or host flavor bindings.
- Q: Which existing app content is authoritative for the first draft of docs? → A: Existing Compose screens, strings, warnings, and feature flows in `feature/intro`, `feature/messaging`, `feature/node`, `feature/map`, `feature/settings`, and `feature/firmware`.
- Q: How should screenshot automation be adapted? → A: Prefer Roborazzi because it fits Gradle-heavy Ubuntu CI and Compose UI; Paparazzi remains acceptable if it better fits the final implementation.
- Q: How should versioning work? → A: Release tags publish immutable versioned docs; `main` publishes `/beta/`; the in-app bundle stays pinned to the shipped app version.
- Q: What is the bundle size ceiling? → A: 10 MB hard limit, 8 MB warning threshold.
- Q: What deep link should external callers use? → A: `meshtastic://meshtastic/settings/help-docs` (canonical, lowercase-hyphenated per project convention). The camelCase form `helpDocs` is accepted as a backward-compatible alias.
## Assumptions
- No existing help system, docs routes, or AI integrations are present in Meshtastic-Android today; this feature introduces the full stack.
- Docs source content is authored in English. Translating long-form docs into the apps 35+ UI languages is out of scope for this feature.
- The published website remains GitHub Pages + Jekyll because that is platform-independent and already familiar to Meshtastic contributors.
- The repos existing CI architecture on `ubuntu-24.04` remains the default execution environment for docs build and screenshot automation.
- Roborazzi is the preferred screenshot technology for Android/KMP automation, but Paparazzi is an acceptable substitute if implementation constraints require it.
- Gemini Nano availability is gated at runtime and may vary by hardware, region, downloaded models, and Google/AICore rollout. Unsupported environments must gracefully fall back to keyword search.
- The `google` flavor can host AI bindings; `fdroid` must remain functional without requiring proprietary AI integrations.
- Chirpy branding will be sourced from the Meshtastic design repository and packaged as a vector-compatible asset for all KMP targets.

View File

@@ -0,0 +1,211 @@
---
description: "Task list for feature: App Documentation (Android/KMP)"
---
# Tasks: App Documentation (Android/KMP)
**Input**: Design documents from `specs/003-app-docs-markdown/`
**Prerequisites**: `spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
**Status**: Not Started
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can be worked in parallel if dependencies are satisfied
- **[Story]**: `US1`..`US5` map to the user stories in `spec.md`
- Every task names the primary file paths to touch
---
## Phase 0: Design Standards Gate (Blocking)
**Purpose**: Review Meshtastic design standards before shipping any new UI for docs or the Chirpy assistant.
- [ ] T000 **[UI-GATE]** Review `.skills/design-standards/SKILL.md` and upstream Meshtastic design standards; record constraints for `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt`, `ChirpyAssistantSheet.kt`, and screenshot styling.
- [ ] T001 **[UI-GATE]** Confirm icon choices in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/` for help/search/info/security states and choose MeshtasticIcons equivalents for docs UI and reference tables.
**Checkpoint**: Design constraints are documented and ready to guide implementation.
---
## Phase 1: Documentation Content
**Purpose**: Author the docs corpus that both the website and in-app browser will consume.
### User Guide pages
- [ ] T010 [P] [US1] Create `docs/user/onboarding.md` covering first launch, intro flow, permissions, and initial setup using content from `feature/intro/src/androidMain/kotlin/org/meshtastic/feature/intro/WelcomeScreen.kt`, `LocationScreen.kt`, and `NotificationsScreen.kt`.
- [ ] T011 [P] [US1] Create `docs/user/connections.md` covering Bluetooth, USB, and TCP connection flows using `feature/intro/.../BluetoothScreen.kt` and `feature/connections/**` as authoritative sources.
- [ ] T012 [P] [US1] Create `docs/user/messages-and-channels.md` covering conversations, channel security, direct messages, and message state using `feature/messaging/src/commonMain/kotlin/org/meshtastic/feature/messaging/Message.kt` and `component/MessageScreenComponents.kt`.
- [ ] T013 [P] [US1] Create `docs/user/nodes.md` covering node list status, roles, badges, and quick actions using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt`.
- [ ] T014 [P] [US1] Create `docs/user/node-metrics.md` covering node detail, device metrics, environment metrics, signal, power, traceroute, and logs using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt` and `metrics/*`.
- [ ] T015 [P] [US1] Create `docs/user/map-and-waypoints.md` covering maps, waypoints, and map-specific actions using `feature/map/src/androidMain/kotlin/org/meshtastic/feature/map/MapScreen.kt`.
- [ ] T016 [P] [US1] Create `docs/user/settings-radio-user.md` covering radio, LoRa, display, and user settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceConfigurationScreen.kt`.
- [ ] T017 [P] [US1] Create `docs/user/settings-module-admin.md` covering module, administration, and advanced settings using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/ModuleConfigurationScreen.kt` and `AdministrationScreen.kt`.
- [ ] T018 [P] [US1] Create `docs/user/telemetry-and-sensors.md` covering telemetry surfaces and sensor interpretation using `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt`, `PowerMetrics.kt`, and related metric screens.
- [ ] T019 [P] [US1] Create `docs/user/tak.md` covering TAK integration and setup using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/TAKConfigItemList.kt` and related settings screens.
- [ ] T020 [P] [US1] Create `docs/user/mqtt.md` covering MQTT setup and usage using `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/MQTTConfigItemList.kt` and messaging references.
- [ ] T021 [P] [US1] Create `docs/user/discovery.md` covering local mesh discovery and node exploration based on current discovery-related UI/state and app navigation flows. **Note**: Feature 001 (Local Mesh Discovery) is Not Started — author this page as a concept/goals overview initially and revise with screenshots and detailed UI guidance once 001 reaches Phase 5+ UI milestones.
- [ ] T022 [P] [US1] Create `docs/user/firmware.md` covering update flows, warnings, and recovery using `feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateScreen.kt`.
- [ ] T023 [P] [US1] Create `docs/user/desktop.md` covering Desktop host usage, transport differences, and parity notes using `desktop/src/main/kotlin/org/meshtastic/desktop/` and shared navigation patterns.
### Developer Guide pages
- [ ] T024 [P] [US4] Create `docs/developer/architecture.md` describing layer boundaries (`app`, `desktop`, `feature/*`, `core/*`) and shared KMP responsibilities.
- [ ] T025 [P] [US4] Create `docs/developer/codebase.md` documenting repository layout, namespacing, and build-logic conventions.
- [ ] T026 [P] [US4] Create `docs/developer/adding-a-feature-module.md` documenting `meshtastic.kmp.feature`, source sets, DI, resources, and testing expectations.
- [ ] T027 [P] [US4] Create `docs/developer/navigation-and-deep-links.md` documenting `Routes.kt`, `DeepLinkRouter.kt`, and Navigation 3 graph registration patterns.
- [ ] T028 [P] [US4] Create `docs/developer/transport.md` documenting BLE, TCP, Serial/USB, and host-specific abstractions.
- [ ] T029 [P] [US4] Create `docs/developer/persistence.md` documenting Room KMP, DataStore/core:prefs, and where docs intentionally do **not** use persistence.
- [ ] T030 [P] [US4] Create `docs/developer/testing.md` documenting KMP test strategy, host tests, and planned screenshot automation.
- [ ] T031 [P] [US4] Create `docs/developer/contributing.md` documenting branch naming, verification, and PR hygiene.
### Content-supporting assets
- [ ] T032 [P] [US1] Create or inventory `docs/assets/screenshots/` references and map each page to required PNG or SVG assets.
- [ ] T033 [P] [US1] Extract onboarding tips, warnings, and disclaimers from `feature/intro/**`, `feature/firmware/**`, and relevant feature UIs into highlighted callout sections inside the authored markdown.
- [ ] T034 [US1] Review all markdown for reference-table compliance where 2+ icon/state captures appear together.
**Checkpoint**: Complete markdown corpus exists with planned screenshots and callouts.
---
## Phase 2: Jekyll Site Setup
**Purpose**: Make the authored markdown browsable on the web with versioning.
- [ ] T040 [P] [US1] Create `docs/_config.yml` with `just-the-docs`, sidebar search, and the required collection/navigation settings.
- [ ] T041 [P] [US1] Create `docs/index.md` redirect behavior for `/latest/` and beta handling.
- [ ] T042 [P] [US1] Create `docs/_data/versions.yml` with an initial `beta` entry and stable release entry schema.
- [ ] T043 [P] [US1] Create any shared include/layout files needed for version selector, beta banner, and consistent screenshot styling.
- [ ] T044 [US1] Validate local Jekyll build output from the authored markdown and confirm the navigation hierarchy matches the spec.
**Checkpoint**: Local website build is navigable and version-ready.
---
## Phase 3: Build Pipeline (Markdown → HTML, Index, Bundle)
**Purpose**: Implement Gradle-native docs generation suitable for KMP.
- [ ] T050 [P] [US1] Create `feature/docs/build.gradle.kts` using `meshtastic.kmp.feature` and dependencies for `core:common`, `core:navigation`, `core:resources`, `core:ui`, `core:di`, and existing markdown renderer libraries.
- [ ] T051 [P] [US1] Add `:feature:docs` to `settings.gradle.kts`.
- [ ] T052 [P] [US1] Add docs-generation support in `build-logic/convention/src/main/kotlin/org/meshtastic/buildlogic/DocsTasks.kt` (or equivalent) with lazy task registration.
- [ ] T053 [P] [US1] Implement frontmatter parsing, nav-order extraction, and markdown normalization in build logic or `feature/docs` build task code.
- [ ] T054 [P] [US1] Implement HTML rendering via `flexmark-java` (or `commonmark-java` fallback) in the docs generation task.
- [ ] T055 [P] [US1] Implement callout and banner post-processing, shared CSS injection, and `data-page` emission for generated HTML.
- [ ] T056 [P] [US1] Generate `index.json` matching `specs/003-app-docs-markdown/contracts/keyword-index-schema.json`.
- [ ] T057 [P] [US1] Wire generated output into `feature/docs/build/generated/docs/common/` as a Gradle resource source directory.
- [ ] T058 [P] [US1] Add Android asset mirroring if required for WebView file loading under `feature/docs/build/generated/docs/androidAssets/`.
- [ ] T059 [P] [US1] Enforce bundle-size warnings/failures and missing-asset validation in `validateDocsBundle`.
- [ ] T060 [US1] Add aggregate root tasks (`generateDocsBundle`, `validateDocsBundle`, `publishDocsSite`) and document their usage.
**Checkpoint**: Gradle can generate the docs bundle and website artifact from markdown.
---
## Phase 4: In-App Doc Browser
**Purpose**: Ship the offline docs browser inside Settings.
- [ ] T070 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/model/DocModels.kt` implementing the entities from `data-model.md`.
- [ ] T071 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/DocBundleLoader.kt` to load packaged docs metadata and page content.
- [ ] T072 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsBrowserScreen.kt` with grouped TOC, search entry point, and loading/empty states.
- [ ] T073 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsPageRouteScreen.kt` to route page IDs to renderer surfaces.
- [ ] T074 [P] [US2] Create Android renderer `feature/docs/src/androidMain/kotlin/org/meshtastic/feature/docs/ui/DocHtmlView.android.kt` using `AndroidView` + `WebView`.
- [ ] T075 [P] [US2] Create Desktop/iOS page renderers in `src/jvmMain` and `src/iosMain` using Compose markdown or embedded browser abstraction.
- [ ] T076 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/navigation/DocsNavigation.kt` with typed navigation entries.
- [ ] T077 [P] [US2] Add `SettingsRoute.HelpDocs` and `SettingsRoute.HelpDocPage` to `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`.
- [ ] T078 [P] [US2] Update `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/DeepLinkRouter.kt` for `help-docs` (canonical) / `helpDocs` (compat alias) routing.
- [ ] T079 [P] [US2] Update `feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt` to add the Help & Documentation row and register docs destinations.
- [ ] T080 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/di/FeatureDocsModule.kt`.
- [ ] T081 [P] [US2] Include `FeatureDocsModule` in `app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt` and `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`.
- [ ] T082 [US2] Add shared/unit tests for bundle loading, page ordering, and route serialization under `feature/docs/src/commonTest/kotlin/org/meshtastic/feature/docs/`.
**Checkpoint**: Help & Documentation opens inside Settings and reads bundled content offline.
---
## Phase 5: Search / Index / Discoverability
**Purpose**: Make the docs corpus searchable on all targets.
- [ ] T090 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/data/KeywordSearchEngine.kt` using `KeywordIndexEntry`.
- [ ] T091 [P] [US2] Add alias normalization and title-first ranking logic.
- [ ] T092 [P] [US2] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/DocsSearchBar.kt` and wire it into `DocsBrowserScreen.kt`.
- [ ] T093 [P] [US2] Add section-aware search results and page suggestions for missing page/deep-link cases.
- [ ] T094 [P] [US2] Add tests for ranking, aliases, and tie-breaking in `KeywordSearchEngineTest.kt`.
- [ ] T095 [US2] Ensure keyword search is the user-visible fallback on unsupported AI targets.
**Checkpoint**: Search works without AI on every target.
---
## Phase 6: AI Assistant (Gemini Nano)
**Purpose**: Add an Android-only on-device assistant without breaking KMP or `fdroid`.
- [ ] T100 [P] [US3] Create shared AI contracts in `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ai/AIDocAssistant.kt` and result/state models.
- [ ] T101 [P] [US3] Create `feature/docs/src/commonMain/kotlin/org/meshtastic/feature/docs/ui/ChirpyAssistantSheet.kt` with chat UI, pinned input, session history, and source-page chips.
- [ ] T102 [P] [US3] Add keyword-retrieval + token-budget helper logic in shared code.
- [ ] T103 [P] [US3] Implement Google-flavor Android binding under `app/src/google/kotlin/org/meshtastic/app/docs/GoogleDocsAiModule.kt` (or equivalent) to call Gemini Nano via Google AI Edge SDK.
- [ ] T104 [P] [US3] Bind a no-op or keyword-only fallback implementation in `app/src/fdroid/kotlin/org/meshtastic/app/di/FlavorModule.kt`.
- [ ] T105 [P] [US3] Bind a Desktop fallback implementation from `desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt`.
- [ ] T105b [P] [US3] Bind an iOS fallback implementation (keyword-search-only, sharing the Desktop fallback pattern) in the iOS Koin module or via a shared non-Android default binding.
- [ ] T106 [P] [US3] Add runtime capability checks for Android API level, flavor, model availability, and busy/quota states.
- [ ] T107 [P] [US3] Surface assistant fallback states cleanly in the shared UI and hide the input entirely when unsupported.
- [ ] T108 [P] [US3] Add tests covering token budget trimming, unsupported platform behavior, and fallback search suggestions.
- [ ] T109 [US3] Verify the Chirpy vector asset is bundled and rendered correctly across targets.
**Checkpoint**: Supported Android Google builds get Gemini Nano; all other targets fall back gracefully.
---
## Phase 7: CI Automation and GitHub Pages
**Purpose**: Keep docs current and deployable.
- [ ] T120 [P] [US5] Create `.github/workflows/docs-deploy.yml` using `ubuntu-24.04`, JDK 21, Gradle setup, docs-generation tasks, and Pages deploy steps.
- [ ] T121 [P] [US5] Create `.github/workflows/docs-release.yml` for `v*.*.*` tags, version manifest updates, and `/latest/` redirect refresh.
- [ ] T122 [P] [US5] Create or wire `recordDocsScreenshots` to the chosen screenshot framework (`Roborazzi` preferred, `Paparazzi` acceptable).
- [ ] T123 [P] [US5] Add screenshot asset diff detection and automated PR creation logic for changed PNGs.
- [ ] T124 [P] [US5] Add schema validation against `specs/003-app-docs-markdown/contracts/keyword-index-schema.json` during CI.
- [ ] T125 [P] [US5] Add bundle-size validation and missing-asset validation to CI as blocking steps.
- [ ] T126 [P] [US5] Update workflow permissions and Pages artifact publishing configuration.
- [ ] T127 [US5] Dry-run the workflows locally as far as practical and verify contract alignment.
**Checkpoint**: Docs build, validate, and deploy automatically in CI.
---
## Phase 8: Polish, Accessibility, and Edge Cases
**Purpose**: Final quality pass before implementation is considered complete.
- [ ] T130 [P] [US2] Add accessibility labels, headings, and focus order checks to docs browser and Chirpy UI.
- [ ] T131 [P] [US2] Validate dark-mode rendering for generated HTML, screenshots, and icon reference tables.
- [ ] T132 [P] [US2] Handle missing-page and stale-deep-link fallbacks in the docs browser UI.
- [ ] T133 [P] [US3] Add explicit user messaging for Gemini busy/quota/model-not-installed states.
- [ ] T134 [P] [US1] Review all pages for plain-language voice, no internal jargon leaks, and consistency with current UI strings.
- [ ] T135 [P] [US4] Review developer docs for correctness against actual modules, routes, and DI setup.
- [ ] T136 [P] [US5] Validate Lighthouse accessibility on the generated site and record results.
- [ ] T137 [P] [US5] Add README updates for Help & Documentation and the deep-link contract.
- [ ] T138 [US1] Run final verification: `./gradlew spotlessCheck detekt kmpSmokeCompile test allTests generateDocsBundle validateDocsBundle publishDocsSite`.
**Checkpoint**: Feature is accessible, correct, and release-ready.
---
## Dependency Notes
- Phase 0 blocks all UI work.
- Phase 1 (content) and Phase 2 (site scaffolding) can overlap.
- Phase 3 must finish before Phase 4 can load generated bundles reliably.
- Phase 5 depends on Phase 3 metadata/index generation and Phase 4 browser UI.
- Phase 6 depends on Phase 5 because AI retrieval uses the keyword index and search engine.
- Phase 7 depends on Phases 2 and 3.
- Phase 8 depends on all preceding phases.
## Recommended Delivery Order
1. Ship **US1** first (web docs + pipeline).
2. Add **US2** (in-app browser + deep links).
3. Add **US3** (Gemini Nano + fallbacks).
4. Finish **US4** polishing and architecture docs.
5. Finish **US5** automation and screenshot bot flow.