diff --git a/android/gradle.properties b/android/gradle.properties index f018a61..475a628 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/docs/plans-archive/2026-06-21-observer-settings-client-design.md b/docs/plans-archive/2026-06-21-observer-settings-client-design.md new file mode 100644 index 0000000..fd74002 --- /dev/null +++ b/docs/plans-archive/2026-06-21-observer-settings-client-design.md @@ -0,0 +1,118 @@ +# Observer Settings — Offband Meshcore **client** design + +- **Feature:** OffbandMesh/meshcore-client#63 · **Planning epic:** #64 +- **Firmware counterpart:** OffbandMesh/meshcore-firmware#142 (impl) + #143 (agreed wire contract) +- **Scope:** the **client** side only. Firmware-owned wire constants are **TBD-from-firmware** (RedCreek). +- **Status:** revised after Gemini adversarial review (2026-06-21, verdict *reconsider* → all findings addressed below, §11) → owner sign-off (AGREE) → implementation. + +Legend: **[F]** Fact (code/contract) · **[A]** Assumption · **[U]** Uncertainty (needs decision/firmware input). + +--- + +## 1. Context +When connected to an **Observer** device (Offband firmware variant bridging LoRa↔MQTT over WiFi), configure its **MQTT brokers, WiFi, display, region** from client forms instead of `_sys` text commands. Firmware decision (locked, #143 "option-2"): one Offband-owned `config` companion command (sub-type set/get/view + paginated broker list), key/value flat settings, contacts-style broker pagination, `_sys` fallback, capability-gated, device-NVS source of truth, secrets write-only/redacted. **[F]** + +**Client today:** zero MQTT/WiFi/Observer support. Shell ready: `settings_shell.dart` (responsive master-detail); categories built in `settings_screen.dart:_categories()` (~L72–131); device read via connector getters, written via `setX()`/`sendFrame()` + refresh; long-press menu pattern exists (`contacts_screen.dart` `onLongPress` + `onSecondaryTapUp`). **[F]** + +## 2. Config contract (from #143) +- `wifi`: `ssid`, `pwd`(**WO**), `enabled` +- `mqtt` global: `iata`, `status_int` +- `mqtt.broker.<0–9>`: `enabled`, `url`, `port`, `transport`(tcp/tls/wss), `auth_type`(none/basic/jwt), `username`, `password`(**WO**), `topic_prefix`, `iata_override`, `jwt_aud`, `jwt_refresh`, `jwt_owner`, `jwt_email`, `ca_cert`, `jwt_token`(**WO**) +- `display`: `always_on`, `rotation`(0/180) + +Secrets never returned by GET. Pool = 10 slots. **[F]** + +## 3. Phasing (revised — de-risk pagination early) +- **Phase 1 — flat settings** (`wifi`/`display`/`iata`/`status_int`) **+ a non-UI broker-list GET in the integration test (T6)** to prove the multi-frame `START→SLOT×N→END` decode — the riskiest path — before any Phase-2 UI is built. (Review finding #4.) +- **Phase 2 — MQTT broker pool UI** (list + editor), building on the now-proven pagination decode. + +## 4. Connector layer +### 4a. `ObserverConfigClient` codec (new `lib/connector/observer_config_client.dart`) +Owns encode/decode of the `config` command only, so UI/models never touch wire bytes. **[A → justified by review #1: a binary, externally-owned, still-TBD wire contract is exactly when an Adapter earns its keep; pragmatic exception to "no helper until 3+ places".]** +- `setFlat(key, value)` / `getFlat(key)` — flat key/value. +- `getBrokers()` → decode the paginated stream into `BrokerConfig[]` (secrets absent). +- `setBrokerField(slot, field, value)` — single field (firmware contract is field-at-a-time). +- `brokerOp(slot, BrokerOp)` — enable/disable/clear. + +**Secrets:** the codec only emits a secret key when the model's intent is `set`/`clear`, never for `unchanged` (see §5) — prevents the blank-field wipe footgun. **[F, review #1]** + +**TBD-from-firmware (gate coding, not design) [U]:** config CMD code + sub-type bytes; broker-slot frame layout + enum encodings; pagination framing. + +### 4b. Capability gating (revised — two-factor, review #3) +`bool get supportsObserverConfig` is true iff **BOTH**: (1) `FIRMWARE_VER_CODE ≥` the firmware gate, **AND** (2) a **`WIFI_OBSERVER_SUPPORT` capability flag** in the `CMD_DEVICE_QUERY` response is set. Version code alone cannot tell a build with `wifi_observer` compiled out from one with it — gating on version only yields a dead category. The category is shown and `config` is sent only when both hold; on reconnect the flag is re-read before the category renders (no stale-handshake flicker). **[F, review #3]** → **firmware-coordination item (§10):** confirm/define the capability flag with RedCreek. + +### 4c. State + refresh +Connector holds parsed `ObserverConfig?` + a getter + `refreshObserverConfig()`. A failed refresh sets a **stale flag** the UI surfaces (never silently shows stale data). `notifyListeners()` on update. **[F, review #6/#7]** + +## 5. Data models (new `lib/models/observer_config.dart`) +`ObserverConfig { WifiConfig wifi; MqttGlobalConfig mqtt; List brokers; DisplayConfig display; }`, `MqttGlobalConfig`, `DisplayConfig`, enums `BrokerTransport`/`BrokerAuthType`/`BrokerOp`. + +**Secret fields use a three-state intent type** (not a bare `String?`): `SecretField { unchanged | clear | set(value) }`. GET yields `unchanged`; the codec sends nothing for `unchanged`, an empty value for `clear`, the value for `set`. This makes "leave / clear / replace" explicit end-to-end. **[F, review #1]** + +**Storage: none** — device NVS is the source of truth; the client reads/writes the device, no `lib/storage/` copy. **[F]** + +## 6. UI (new `lib/screens/settings/observer_settings_view.dart`) +### 6a. Category +One `SettingsCategory(title:"Observer", builder:_observerPane)` added to `_categories()`, **conditionally** on `connector.supportsObserverConfig`. **[F]** + +### 6b. Save model — ONE rule for the whole pane (review #5) +**Every control edits staged local state; nothing is sent to the device until an explicit "Save".** Toggles flip visually on tap but only transmit (`setFlat`) on Save, alongside all other pending changes. Consistent, predictable; no "did my toggle apply?" ambiguity. A per-pane Save (✓/disk) + Refresh live in the app bar. **[F, review #5]** + +### 6c. WiFi (P1) +SSID (text), Password (**three-state secret** — renders `•••• (set, hidden)`; untouched ⇒ not sent; clear button ⇒ `(cleared)` ⇒ sends empty; typing ⇒ `set`), Enabled (toggle). Status line (`get wifi.status`) + refresh. Save sends only changed fields. **[F, review #1/#5]** + +### 6d. Display + Region/status (P1) +`always_on`, `rotation` (segmented), `iata`, `status_int` (10–3600) — all staged, sent on Save. + +### 6e. MQTT brokers (P2) — owner UX +- **List shows all 10 slots** (resource constraint visible); empty slots greyed with a "Configure"/+ affordance, populated slots show url + enabled badge. **[F, review #8]** +- **Tap** → broker editor (app-bar Refresh + Save). +- **Long-press / desktop right-click** → menu **Enable / Disable / Edit / Clear** → `brokerOp()` / open editor. Reuses the contacts pattern. **[F]** + +### 6f. Broker editor (P2) +Fields per `BrokerConfig`; `transport`/`auth_type` dropdowns; auth fields conditional; secrets three-state. **Save = send all changed fields**, then **on ANY field failure, immediately auto-`getBrokers()` and render the device's actual (possibly partial) state + a clear "save was partial" error** — never leave the UI asserting a state the device doesn't hold. **[F, review #2 — mitigates the non-atomic field-at-a-time contract.]** + +## 7. State / disconnect / errors +- **In-flight edits on disconnect:** preserve the user's staged input, **disable Save**, show a "reconnect to save" banner; restore on reconnect — never silently clear the form. **[F, review #7]** +- **Error visibility (SAFELANE §6):** every `config` round-trip is `await`ed in `try/catch`; failures surface a **persistent** (dismissible) SnackBar/banner in the pane **and** `_appDebugLogService.error(…)`. No fire-and-forget sends, no silent refresh no-ops. **[F, review #6]** + +## 8. File-overlap analysis (File-Convergence Rule) +| Area | Files | +|---|---| +| Models | `lib/models/observer_config.dart` *(new)* | +| Codec | `lib/connector/observer_config_client.dart` *(new)* + `lib/connector/meshcore_connector.dart` *(hook + capability + state)* | +| UI | `lib/screens/settings/observer_settings_view.dart` *(new)* + `lib/screens/settings_screen.dart` *(registration)* | + +**Serialize** tasks touching `meshcore_connector.dart` and `settings_screen.dart`; new files parallelize. **[F]** + +## 9. Task breakdown (1–3 files each) +**Phase 1** (dep: firmware F1 constants + capability flag): +- **T1** models *(new)*. +- **T2** `ObserverConfigClient` flat set/get **+ `getBrokers()` decode** *(new + connector hook)*. dep: T1, firmware constants. +- **T3** two-factor capability gating *(connector)*. dep: T2. +- **T4** Observer category + WiFi pane (staged-save model) *(settings_screen.dart + observer_settings_view.dart)*. dep: T2, T3. +- **T5** Display + Region/status panes *(observer_settings_view.dart)*. dep: T4. +- **T6 integration test** — Phase-1 round-trip **+ a non-UI `getBrokers()` decode** against a firmware F1 build (proves pagination early). dep: T4, T5. + +**Phase 2** (dep: T6): +- **T7** broker list UI (all-10-slots) *(observer_settings_view.dart)*. dep: T6. +- **T8** broker editor + staged save + partial-failure auto-refresh *(observer_settings_view.dart)*. dep: T7. +- **T9** long-press Enable/Disable/Edit/Clear *(observer_settings_view.dart)*. dep: T7. +- **T10 integration test** — Phase-2 round-trip. dep: T8, T9. + +## 10. Firmware-owned constants + coordination (RedCreek / #142) +TBD constants: (1) `config` CMD code + sub-type bytes; (2) broker-slot frame layout + enum encodings; (3) `app_target_ver` gate. **Coordination items raised by the review:** (4) a **`WIFI_OBSERVER_SUPPORT` capability flag** in `CMD_DEVICE_QUERY` (version code alone is insufficient — review #3); (5) confirm the broker SET is field-at-a-time (no transactional `setBroker`) so the client must own the partial-failure mitigation (review #2). (6) a Phase-1 build to test against. Client coding holds until (1)–(3) land. + +## 11. Review responses (Gemini gemini-2.5-pro, 2026-06-21 — standards#145: fix or justify, no silent dismissals) +| # | Finding | Sev | Disposition | +|---|---|---|---| +| 1 | Write-only secret UX ambiguous → accidental wipe | BLOCKER | **Fixed** — three-state `SecretField` (§5) + three-state input UX (§6c/§6f); codec never sends `unchanged` (§4a). | +| 2 | Field-at-a-time broker save → partial/corrupt state | BLOCKER | **Fixed (client mitigation)** — on any field failure, auto-`getBrokers()` + render actual state + partial-error (§6f). **Coordinating** firmware transactional-set feasibility (§10.5). | +| 3 | Version-code-only capability gating → dead UI | MAJOR | **Fixed** — two-factor: version + `WIFI_OBSERVER_SUPPORT` flag (§4b); re-read on reconnect. **Coordinating** the flag with firmware (§10.4). | +| 4 | Phasing defers riskiest (pagination) to P2 | MAJOR | **Fixed** — minimal non-UI `getBrokers()` decode pulled into P1 integration test T6 (§3, §9). | +| 5 | Inconsistent immediate-vs-explicit save | MAJOR | **Fixed** — single rule: stage locally, commit on explicit per-pane Save (§6b). | +| — | In-flight edits lost on disconnect | (ans 7) | **Fixed** — preserve input, disable Save, restore on reconnect (§7). | +| — | Broker list all-10 vs populated | (ans 8) | **Fixed** — show all 10, empty slots distinct (§6e). | +| — | Silent-failure surfaces | (ans 6) | **Fixed** — awaited try/catch + persistent surfacing + stale-flag on refresh fail (§4c/§7). | + +Verdict was *reconsider*; all BLOCKER/MAJOR findings are now addressed in-design above. diff --git a/docs/plans-archive/2026-06-22-observer-phase1-plan-and-audit.md b/docs/plans-archive/2026-06-22-observer-phase1-plan-and-audit.md new file mode 100644 index 0000000..8da9c2d --- /dev/null +++ b/docs/plans-archive/2026-06-22-observer-phase1-plan-and-audit.md @@ -0,0 +1,93 @@ +# Observer Config (client) — Phase 1 plan **v2 (REVISED, security-first)** + +- **Epic:** OffbandMesh/meshcore-client **#64** · **Feature:** #63 · **Firmware contract:** F1 #160 (delivered, `OffbandConfigProtocol.h`) +- **Date:** 2026-06-22 · **Standards:** @96da86c · **Status:** awaiting Ben AGREE +- **v1 was rejected.** v1 self-validated my own code ("MET, analyze-clean") and had no threat model. A Gemini adversarial review then found **2 BLOCKERs in the very layers v1 graded "MET"** plus 2 MAJOR / 2 MINOR (`docs/llm-consultations/2026-06-22-observer-phase1-security-review.md`). This version treats the existing commits as an **UNTRUSTED DRAFT**, builds a threat model first, turns every finding into a sized fixing task with the adversarial test **written first**, and gates a device test behind a *re-review*, not behind my word. + +--- + +## 1. Threat model (this is what v1 was missing) + +| | | +|---|---| +| **Assets** | Write-only secrets (MQTT/JWT credentials, `wifi.pwd`, `ca_cert`); client availability/stability; config integrity. | +| **Adversary** | A malicious or compromised observer device. It controls **every inbound byte and the order of frames** over BLE/USB/TCP. | +| **Capabilities** | Send arbitrary/malformed frames; omit `END`; stream unbounded `BROKER_KV`; spoof codes/sub-types; race/duplicate frames; supply garbage for numeric/enum fields; out-of-range slot indices. | +| **Trust boundary** | The transport. **Everything inbound is untrusted.** `observer_config_client.parse()` + `BrokerListDecoder` + `ObserverConfigService` ARE the parsing trust boundary — they must be hostile-input-safe, not just contract-happy. | +| **Non-goals (this phase)** | Transport encryption/auth (owned by the BLE/companion layer); broker enable/clear (firmware gap). | + +--- + +## 2. Gemini findings → fixing task (nothing dropped) + +| # | Sev | Finding | Fixed by | +|---|-----|---------|----------| +| 1 | **BLOCKER** | Race: "one request in flight" assumed, not enforced; shared `receivedFrames` + order-correlation → wrong `Completer` completes | **S1** (single-flight queue) + **A2** (proves it) | +| 2 | **BLOCKER** | DoS: `BrokerListDecoder` grows unbounded on a no-`END` `BROKER_KV` flood → OOM | **C1** (decoder bounds) + **A1** (proves it) | +| 3 | MAJOR | Plan has no threat model / adversarial tests | **TM** (§1) + **A1/A2** (this plan) | +| 4 | MAJOR | `offband_caps` byte-82 offset unverified; short-frame robustness | **N1** (guard + device-verify) | +| 5 | MINOR | Broker slot index not range-checked (`slot: 200` accepted) | **C1** (validate `< brokerSlotCount`) | +| 6 | MINOR | Secret **keys** leak into error strings (`SET wifi.pwd failed…`) | **S1** (redact secret keys) | + +--- + +## 3. Revised sized tasks (1–3 files; **adversarial tests written FIRST**) + +| ID | Title | Files | Depends on | +|----|-------|-------|------------| +| **TM** | Threat model + this revised plan (AGREE gate) | *(this doc)* | — | +| **A1** | **Adversarial codec test suite** — malformed/short frames, missing NUL, OOB slot, non-numeric numerics, no-`END`/unbounded `BROKER_KV`, duplicate `START`. Must FAIL on current code first. | `test/observer_config_client_test.dart` *(new)* | TM | +| **C1** | Harden codec: validate slot `< brokerSlotCount` in `parse`; bound `BrokerListDecoder` (cap entries at 10, drop OOB, cap fields/slot). Fixes #2, #5. | `lib/connector/observer_config_client.dart` | A1 | +| **A2** | **Service test suite** — overlapping `refresh()`/`setFlat()` must serialize (no cross-talk); secret-key redaction; timeout releases decoder. Must FAIL first. | `test/observer_config_service_test.dart` *(new)* | TM | +| **S1** | Service single-flight queue/mutex (fixes #1) + redact secret keys in errors (fixes #6). | `lib/services/observer_config_service.dart` | A2, C1 | +| **M1** | Models (existing draft; minor). | `lib/models/observer_config.dart` | TM | +| **N1** | Connector cap-read: guard short frames; **verify byte-82 offset against real device-info frames** (device sub-gate). Fixes #4. | `lib/connector/meshcore_connector.dart` | TM | +| **P1** | Provider wiring. | `lib/main.dart` | S1 | +| **U1** | UI + capability-gated category. | `lib/screens/settings/observer_settings_view.dart`, `lib/screens/settings_screen.dart` | S1, P1 | +| **G1** | **Re-run Gemini adversarial review** on hardened code — must clear both BLOCKERs. | *(gate)* | A1, A2, C1, S1 | +| **G2** | **Human device test on a live observer node (Ben).** Integration gate. | *(gate)* | G1, N1, U1 | +| **PR** | ONE PR at completion + Ben approval → merge. | *(gate)* | G2 | + +**File-overlap:** every source file is owned by exactly one task (codec→C1, service→S1, connector→N1, main→P1, UI→U1, models→M1; tests are new files). No intra-epic conflicts. + +**Dependency chain** +``` +TM(threat model + AGREE) + ├─ A1 ─ C1 ─┐ + ├─ A2 ──────┴─ S1 ─ P1 ─ U1 ─┐ + ├─ M1 │ + └─ N1 (device-verify offset) ─┤ + └─ G1 (re-Gemini, clears BLOCKERs) ─ G2 (Ben device test) ─ PR +``` + +--- + +## 4. Gates (in order, none skippable) +1. **AGREE** — Ben approves THIS plan before any code (the gate I skipped in v1). +2. One claimed Citadel task per ID; each is a child issue of #64 on board #2. +3. **Adversarial tests (A1/A2) written and failing before the fix; passing after.** +4. **G1 re-review** must clear both BLOCKERs. +5. **G2 — Ben device test** is the integration gate. I cannot clear it. +6. **ONE PR** at completion, after G2, with Ben's explicit approval. + +--- + +## 5. The guardrails I skipped → the exact vulnerability each would have caught + +This is the honest accounting. Every one of these was a guardrail I bypassed, and every bypass let a specific defect through: + +| Guardrail I skipped | What it would have caught | +|---|---| +| **AGREE on a plan w/ threat model before code** | Forces modeling the adversary before writing an untrusted-input parser → the **DoS** and **race** are designed out, not discovered later. | +| **Adversarial / Gemini review before publishing** | Gemini caught **both BLOCKERs in one pass**. I self-validated and graded the buggy codec + service "MET." | +| **TDD / adversarial tests first** | A malformed-frame + unbounded-stream test would have **failed immediately** — instead the OOB slot and unbounded decoder shipped *analyze-clean*. | +| **Human-test gate before PR** | I put code that **crashes under normal use** (the race) and **crashes on a hostile device** (the DoS) onto the merge track, and asked you to merge it. | +| **"analyze-clean ≠ tested/secure"** | I treated a clean static analysis as done. Two crash-class bugs passed it without a whimper. | +| **Treat device input as untrusted** | The whole bug class — I wrote a parser for attacker-controlled bytes as if the bytes were trusted. | + +--- + +## 6. Status of the existing commits (`1983ed9`, `12f5f97`, PR #67) +**Untrusted draft — not a basis to test.** They are analyze-clean but contain both BLOCKERs. They are useful as a starting point for M1/U1/etc., but each file is rewritten/hardened under its task and must pass A1/A2 + G1 before G2. PR #67 should be pulled down (mid-epic, pre-test, contains the BLOCKERs). + +**Nothing proceeds until Ben AGREEs this v2.** diff --git a/docs/trace-topology-spec.md b/docs/trace-topology-spec.md new file mode 100644 index 0000000..ae6ef4e --- /dev/null +++ b/docs/trace-topology-spec.md @@ -0,0 +1,132 @@ +# Passive Mesh Topology → Inferred Trace Routes (design spec) + +**Status:** proposal / design review (2026-07-05). Owner: Offband. Related: #150 (trace), the RX-fed nearest-repeater work, `meshcore-firmware/docs/trace-path-format.md`. + +## 1. The problem we're solving + +A MeshCore TRACE only walks a route **the caller hands it**. The firmware never discovers or reverses a route (`trace-path-format.md`), and the reference apps make the user **hand-build** the path (pick nodes from a list, or click them on a map). So multi-hop trace today is either a clunky manual chore or it silently fails. No client discovers the route for you — that's the gap. + +**The opportunity:** we *already* parse every received packet's full routing path (for the new RX-fed nearest-repeater detection in `_handleRxData`). That firehose is a **passive map of the mesh**. If we accumulate it into a topology graph, we can **infer** a route to a target we've observed — and auto-suggest it, with a builder to confirm/tweak. The firmware can't discover routes; the *client* can remember what it has already seen. That's the thing no client does. + +## 2. Core idea, in one line + +Every received packet carries the ordered list of repeaters it crossed. Merge those observations into a graph; to trace node *T*, find a path to *T* on the graph and hand it to the existing round-trip builder. + +## 3. What we already have (reuse, don't rebuild) + +| Existing | Role in this feature | +|---|---| +| `_handleRxData` → `pathBytes` (per packet) | The observation firehose — the ordered hops a packet crossed. Already parsed; today only adverts are used. | +| `_handlePayloadAdvertReceived`, `_handleLogRxData` | Additional path observations (adverts, channel messages). | +| `DirectRepeater` list (RX-fed) | The **first ring** of the graph — our direct neighbours + their SNR. The only hops we can SNR-rank. | +| `PathHistoryService` | Per-contact observed ACK/flood paths — another observation input; the graph generalises it. | +| `PathHelper.buildTraceRoundTrip(routingPath, targetPrefix, throughTarget, …)` | Assembles the out-and-back frame from a route. Inference output feeds straight into this. | +| `PathSelectionDialog` | The manual route builder — becomes the confirm/edit surface, pre-filled from inference. | + +## 4. Path orientation (get this right) + +A received flooded/routed packet's `pathBytes` = `[h1, h2, …, hk]`, where the packet travelled `origin → h1 → … → hk → us`. So: +- `hk` (= `pathBytes.last`) is **our direct neighbour** (the last hop before us). +- `h1` is the hop closest to the origin. +- The reversed path `[hk, …, h1]` is a **known route from us toward `origin`**. + +Adjacencies contributed by one observation: `us~hk`, `hk~h(k-1)`, …, `h2~h1`, and `h1~origin` when the sender prefix is known. + +## 5. Data model — `MeshTopologyService` (new, ChangeNotifier) + +Graph, scoped per device key (like the other stores). + +``` +Node { + prefix : Uint8List // width-byte repeater hash (the key) + lastSeen : int // unix seconds, refreshed on every observation + neighbourSnr : double? // only set for OUR direct neighbours (from RX SNR); null for deep hops + seenCount : int + // name is resolved on demand from contacts — NOT stored here +} + +Edge { // undirected link A~B ("observed adjacent in a path") + a, b : Uint8List // the two endpoints (store canonically ordered) + lastSeen : int + count : int // observation confidence + lastFailed : int? // set when a trace routed through this link timed out (self-healing) +} +``` + +Sizes (why storage is a non-issue): ~8 B/node, ~12 B/edge. Scales with **mesh size** (distinct repeaters × their links), **not** hop depth or packet volume — each node/link is stored once and merely refreshed. 100 repeaters ≈ 6 KB; 1,000 ≈ 80 KB (≈200 KB as JSON). See §9 for the hard bound. + +## 6. Ingestion + +On every parsed RX path (from `_handleRxData` and the advert/channel handlers), call: + +``` +bool observePath(List path, {int width, double? lastHopSnr, Uint8List? senderPrefix, int ts}) +``` + +- Chunk `path` into width-byte hops. +- Upsert a `Node` per hop (refresh `lastSeen`, bump `seenCount`); set `neighbourSnr` on `hk` from `lastHopSnr`. +- Upsert the `us~hk … h1~origin` `Edge`s (refresh `lastSeen`, bump `count`). +- Return `true` only on a **notify-worthy change** (new node/edge, or a materially newer observation) — same notify-on-change discipline as `DirectRepeater.recordHop`. +- Cost: `O(path length)` ≤ 64. The parse already happened; this is a handful of map writes. No new per-packet parsing. + +`DirectRepeater.recordHop` and `observePath` can share the same call site — the direct-neighbour list is just the graph's first ring. + +## 7. Route inference + +``` +List? inferRoute(Uint8List target) // ordered hops us→…→(excluding target); null if unreachable +``` + +- Target is a **direct neighbour** → return `[]` (one-hop ping is correct). +- Else run a shortest-path search (BFS by hop count) from `us` to `target` over the **fresh** subgraph (edges within the freshness window §9). Among equal-length routes, prefer: (1) strongest **first-hop** SNR (we can rank the entry neighbour), (2) freshest edges, (3) highest `count`, (4) no recent `lastFailed`. +- Return the intermediate hops `[n1, …, n(m-1)]`. The trace launch then calls `buildTraceRoundTrip(routingPath: that, targetPrefix: target, throughTarget: true)` — existing, tested code. +- `null` when `target` isn't in the fresh graph → manual builder. + +**Honest limit:** we measure SNR only for **our own** receptions, i.e. the first hop. Deep-link quality isn't observable from here, so intermediate hops are chosen topologically (shortest + freshest + most-seen), not by signal. That's still far better than a blind guess or a manual build, and the first hop — the one most likely to matter — *is* SNR-optimised. + +## 8. Trace UX (replaces the guess) + +Repeater / contact **Path Trace**: +1. Committed `contact.path` exists → trace it (today's `hasRoute`). +2. Else `inferRoute(target)`: + - **Route found** → open the builder **pre-filled** with the suggestion (`Suggested: A → B → target`), one tap to trace or edit first. (Setting: auto-run vs always-confirm.) + - **No route** → open the **empty** builder with an honest hint: *"No route learned yet — build one, or send it a flood ping / catch an advert to learn it."* +3. **Delete the strongest-repeater guess entirely.** The builder is always the transparent surface; inference just pre-fills it. + +## 9. Persistence & bounding (storage is capped by design) + +- Persist the **graph** (deduped nodes + edges), never raw paths — raw paths are the only thing that would bloat. +- **Freshness prune:** on load/save, drop nodes/edges whose `lastSeen` is older than `W` (default 7 days; configurable). Keeps the map to the *live* mesh, not all-time. +- **Hard cap:** LRU by `lastSeen` at a fixed ceiling (default ~2,000 nodes / ~256 KB). Physically can't run away, even pathologically. +- Store: `topology_store` keyed by the first 10 hex of the device pubkey, like the others. + +## 10. Failure modes & honesty + +- **Stale route** (topology moved): the trace just times out like any wrong route; we stamp `lastFailed` on that route's edges so the next inference avoids it. Self-healing over time. +- **Asymmetric / duty-cycled return leg** (firmware `#2`): inference can't fully solve this — a route good outbound may not return. Preferring recently- and frequently-observed links mitigates it; a later refinement can weight bidirectionally-seen links higher. +- **Unobserved target:** never heard, directly or as an intermediate → no inference → manual builder. Expected and honest. + +## 11. Why this is the differentiator + +The firmware *cannot* discover routes (no flood-trace; empty route = error 1). The reference clients push that cost onto the user (manual build). We turn traffic we're **already parsing** into a route oracle that **improves the longer the app runs** — auto-suggesting routes nobody else can. It degrades gracefully to the same manual builder everyone else has, so we're never worse and usually better. + +## 12. Phased build (epic → issues, filed once this design is approved) + +| Phase | Deliverable | +|---|---| +| **A — design** | This spec. | +| **B — core** | `MeshTopologyService` (graph, `observePath`, `inferRoute`) + unit tests (orientation, dedup, pathfind, bound). Wire ingestion into `_handleRxData`/advert/channel. No UI. | +| **C — UX** | Trace launch uses `inferRoute` → pre-filled `PathSelectionDialog`; delete the guess. | +| **D — persist + bound** | `topology_store`, freshness prune, LRU cap. | +| **E — polish** | `lastFailed` confidence decay; optional on-map topology overlay; auto-run vs confirm setting. | + +Phases B–D are the working feature; A is done; E is gravy. + +## 13. Decisions & open questions + +**Decided (Ben, 2026-07-05):** +- **Separate services.** `PathHistoryService` stays the message-router weighter; `MeshTopologyService` is a distinct trace oracle that *reads* PathHistory's observations as one input — no merge. +- **Confirm-first.** An inferred route **pre-fills the builder** and the user confirms/edits before it traces (transparent). Auto-run is a later opt-in setting, not the default. + +**Still open (tuning, not blocking):** +- **Freshness window `W`** — 7 days is a starting guess; tune against real advert/traffic cadence (repeaters now advert ~12 h). diff --git a/docs/windows-distribution-and-versioning.md b/docs/windows-distribution-and-versioning.md new file mode 100644 index 0000000..c652c0b --- /dev/null +++ b/docs/windows-distribution-and-versioning.md @@ -0,0 +1,84 @@ +# Windows distribution, logs, versioning, signing — notes (2026-06-23) + +Reference for shipping the Offband Meshcore Windows desktop build. Answers four +questions raised before the first `feat/64` Windows compile. + +## 1. Installer / short-term distribution + +A Flutter Windows release is a **folder**, not a single exe: +`build/windows/x64/runner/Release/` = `meshcore_open.exe` + `flutter_windows.dll` ++ plugin DLLs + `data/` (`app.so`, `flutter_assets/`, `icudtl.dat`). The exe will +**not** run on its own — it needs the whole folder. + +**Short-term (zero tooling):** zip the entire `Release/` folder → user extracts → +runs the exe. This is effectively what we do now (running a copied exe). +- Caveat: a truly clean machine may need the **MSVC runtime** (VC++ redistributable). + Present on most Win10/11; bundle the redist or note it if a tester hits a DLL error. +- Optional polish without an installer: a **self-extracting zip** (7-Zip SFX) so + the user double-clicks one .exe that unpacks + can auto-launch. + +**Real installers, when ready (rough effort):** +| Option | Effort | Notes | +|---|---|---| +| **MSIX** (`msix` Dart pkg) | Low | `dart run msix:create` packages `Release/` → `.msix`; clean install/uninstall; self-sign or CA-sign; Store-ready. Easiest path, ties in signing. | +| **Inno Setup** | Low–Med | Free; short `.iss` script → familiar `setup.exe`. Most control over UX/shortcuts. | +| **MSI / WiX** | High | Enterprise/group-policy deployment. Overkill for us now. | + +**Recommendation:** zip the folder for testing now; **MSIX** when we want a clean +install + signing in one step (or Inno Setup if we want a traditional setup.exe). + +## 2. Where log files go (today: nowhere) + +There are **no log files on disk.** Logging is two in-app, in-memory ring buffers: +- **BLE/frame debug log** — `BleDebugLogService`, **500-entry** ring, Copy-to-clipboard + (the "RX CODE_192 …" dumps). Captures every transport (BLE/USB/TCP). +- **App debug log** — `AppDebugLogService`, structured app messages. + +`debugPrint` goes to stdout, which a **double-clicked release exe has no console for**, +and is largely stripped in release mode anyway. So on a release Windows build the +**only** diagnostics are the in-app log viewers (copy/paste). + +**Gap for desktop + real users:** no findable log file for support. If we want one, +add a small file logger writing to `getApplicationSupportDirectory()` → +`%APPDATA%\app.offband.meshcore\logs\` on Windows (rotating, capped). Small task; +worth it before non-technical users are on desktop. (Filed as a follow-up if wanted.) + +## 3. Versioning — current state + the gap + +- **pubspec:** `1.1.1+18` (feat/64 == dev). +- **Tags present:** `v1.0.0`, `v1.1.0-rc.1/2/3`, `v1.1.2-ble-test`, `v1.1.2-queuediag` + (+ old upstream `Alpha*`). The `observer-g2-rc8/rc9` GitHub pre-releases I made this + session are remote-only tags (not semver). +- **The gap (honest):** I have **not** been versioning per SAFELANE §Versioning. + - This session's commits (#82, #88, #89, #90, #93) did **not** bump pubspec. + - rc8/rc9 APKs shipped as `+28`/`+29` via `--build-number` overrides, so the + pubspec build number (`+18`) lags what shipped. + - Tags already point at **1.1.2** but pubspec still says **1.1.1** — inconsistent. + +**Cleanup proposal:** +1. Decide the marketing version for this batch. Tags imply **1.1.2** — bump pubspec + to `1.1.2+30` (next build after the +29 that shipped). +2. Going forward: bump pubspec **in the milestone commit** and tag `vX.Y.Z` + (or `-rc.N`) — stop relying on `--build-number` without a matching pubspec bump. +3. Or wire build-time stamping (`git rev-list --count HEAD`) for the build number so + it can't drift (CLAUDE.local notes this as preferred). + +## 4. Windows code signing + +Unsigned exe **runs**, but SmartScreen shows "Windows protected your PC / Unknown +publisher" → user clicks **More info → Run anyway**. Annoying, not blocking. + +**Cert options (separate from our Android keystore):** +| Cert | Cost | SmartScreen | +|---|---|---| +| **Self-signed** | free | warns unless the user installs/trusts the cert first (OK for known testers; MSIX sideload trusts it once) | +| **OV** (Sectigo/DigiCert…) | ~$100–300/yr | removes "unknown publisher"; reputation builds with downloads over time | +| **EV** (+ hardware token/HSM) | ~$300–600/yr | **instant** SmartScreen trust, no warning day one | + +**Signing mechanics are easy** (`signtool sign /fd SHA256 /f cert.pfx ...`, or the +`msix` package signs for you). The cost/effort is **getting a trusted cert** (CA +identity verification + annual fee + an EV token if EV). + +**Do we bother?** +- **Short-term (you + a few testers):** No. The one-time "Run anyway" is fine. +- **Public release:** Yes — at least an **OV** cert (or **EV** for zero-warning). diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index ebe2caf..8bea53c 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -26,6 +26,7 @@ import '../services/linux_ble_pairing_service_stub.dart' if (dart.library.io) '../services/linux_ble_pairing_service.dart'; import '../services/message_retry_service.dart'; import '../services/path_history_service.dart'; +import '../services/mesh_topology_service.dart'; import '../services/app_settings_service.dart'; import '../services/background_service.dart'; import '../services/timeout_prediction_service.dart'; @@ -105,6 +106,55 @@ class DirectRepeater { return DateTime.now().difference(lastUpdated) > const Duration(minutes: maxAgeMinutes); } + + /// Records a directly-heard hop [prefix] at [snr] into [list] — the SNR-ranked + /// set of nearest direct repeaters, capped at [cap]. Updates an existing + /// entry's SNR + timestamp, or adds a new one, evicting the weakest by SNR + /// only when the newcomer is stronger. Returns true when the change is worth a + /// notify: a new entry, an eviction, or an SNR move of at least [snrDelta] dB; + /// sub-threshold jitter refreshes the value silently and returns false. Fed + /// from every routed RX packet (not just adverts), so it tracks live traffic + /// rather than the ~12h advert cadence. (#150) + static bool recordHop( + List list, + Uint8List prefix, + double snr, { + int cap = 5, + double snrDelta = 1.0, + }) { + if (prefix.isEmpty) return false; + for (final r in list) { + if (_prefixEquals(r.pubkeyPrefix, prefix)) { + final moved = (r.snr - snr).abs() >= snrDelta; + r.update(snr); + return moved; + } + } + if (list.length >= cap) { + var weakest = list.first; + for (final r in list) { + if (r.snr < weakest.snr) weakest = r; + } + if (snr <= weakest.snr) return false; // too weak to displace anyone + list.remove(weakest); + } + list.add( + DirectRepeater( + pubkeyFirstByte: prefix.last, + pubkeyPrefix: Uint8List.fromList(prefix), + snr: snr, + ), + ); + return true; + } + + static bool _prefixEquals(List a, List b) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } } enum MeshCoreConnectionState { @@ -312,6 +362,7 @@ class MeshCoreConnector extends ChangeNotifier { // Services MessageRetryService? _retryService; PathHistoryService? _pathHistoryService; + MeshTopologyService? _topologyService; AppSettingsService? _appSettingsService; BackgroundService? _backgroundService; final NotificationService _notificationService = NotificationService(); @@ -415,6 +466,7 @@ class MeshCoreConnector extends ChangeNotifier { bool get hasLoadedChannels => _hasLoadedChannels; Stream get receivedFrames => _receivedFramesController.stream; Uint8List? get selfPublicKey => _selfPublicKey; + MeshTopologyService? get topology => _topologyService; String get selfPublicKeyHex => pubKeyToHex(_selfPublicKey ?? Uint8List(0)); String? get selfName => _selfName; double? get selfLatitude => _selfLatitude; @@ -947,6 +999,7 @@ class MeshCoreConnector extends ChangeNotifier { void initialize({ required MessageRetryService retryService, required PathHistoryService pathHistoryService, + MeshTopologyService? topologyService, AppSettingsService? appSettingsService, TranslationService? translationService, BleDebugLogService? bleDebugLogService, @@ -956,6 +1009,7 @@ class MeshCoreConnector extends ChangeNotifier { }) { _retryService = retryService; _pathHistoryService = pathHistoryService; + _topologyService = topologyService; _appSettingsService = appSettingsService; _translationService = translationService; _bleDebugLogService = bleDebugLogService; @@ -6451,6 +6505,27 @@ class MeshCoreConnector extends ChangeNotifier { final pathBytes = packet.readBytes(pathByteLen); final payload = packet.readBytes(packet.remaining); + // #186: feed the passive topology map from every routed packet's path — + // the same firehose that feeds nearest-repeater detection. Cheap: the + // path is already parsed here. Guarded so a topology-side fault can never + // stall the RX dispatch that also drives message handling. + if (pathBytes.isNotEmpty) { + try { + final sp = _selfPublicKey; + if (sp != null) _topologyService?.setSelf(sp, _pathHashByteWidth); + _topologyService?.observePath( + pathBytes, + width: _pathHashByteWidth, + lastHopSnr: snr, + ); + } catch (e) { + _appDebugLogService?.warn( + 'topology ingest failed: $e', + tag: 'Topology', + ); + } + } + final rawPacket = frame.sublist(3); switch (payloadType) { case payloadTypeADVERT: @@ -6463,6 +6538,21 @@ class MeshCoreConnector extends ChangeNotifier { ); break; default: + // #150: every routed packet's last hop is a directly-heard relay. + // Feed the nearest-repeater SNR from it so detection tracks live + // traffic, not just the ~12h advert cadence. SNR + path are already + // parsed above; recordHop is O(<=5) and notifies only on change. + if (pathBytes.isNotEmpty) { + final width = _pathHashByteWidth; + final prefix = Uint8List.fromList( + pathBytes.sublist( + pathBytes.length >= width ? pathBytes.length - width : 0, + ), + ); + if (DirectRepeater.recordHop(_directRepeaters, prefix, snr)) { + notifyListeners(); + } + } } } catch (e) { appLogger.warn('Malformed RX frame: $e', tag: 'Connector'); @@ -6689,62 +6779,34 @@ class MeshCoreConnector extends ChangeNotifier { } void _updateDirectRepeater(Contact contact, double snr, Uint8List path) { - final pubkeyFirstByte = path.isNotEmpty - ? path.last - : contact.publicKey.first; - // Capture the last hop's FULL configured-width prefix (the directly-heard - // repeater), not just one byte, so wide-prefix meshes resolve it correctly. (#151) + // A chat/sensor advert with no path gives us no last-hop repeater to learn. + if ((contact.type == advTypeChat || contact.type == advTypeSensor) && + path.isEmpty) { + return; + } + + // The last hop's full configured-width prefix is the directly-heard + // repeater; fall back to the node's own prefix for a zero-hop advert. (#151) final width = _pathHashByteWidth; - final Uint8List pubkeyPrefix; + final Uint8List prefix; if (path.isNotEmpty) { - pubkeyPrefix = Uint8List.fromList( + prefix = Uint8List.fromList( path.sublist(path.length >= width ? path.length - width : 0), ); } else { final pk = contact.publicKey; - pubkeyPrefix = Uint8List.fromList( + prefix = Uint8List.fromList( pk.length >= width ? pk.sublist(0, width) : pk, ); } - _directRepeaters.removeWhere((r) => r.isStale()); - - //We can use adverts from chat and sensor nodes, but only if the advert has a path to get the last hop. - if ((contact.type == advTypeChat || contact.type == advTypeSensor) && - path.isEmpty) { + // No 30-min hard eviction: a known direct repeater stays usable for routing + // between reads. recordHop self-caps the list at 5 (weakest-SNR), and + // isStale()/ranking still mark freshness for display. Notify only on a real + // change to avoid per-packet UI churn. (#150) + if (DirectRepeater.recordHop(_directRepeaters, prefix, snr)) { notifyListeners(); - return; - } - - final isTracked = _directRepeaters.where( - (r) => listEquals(r.pubkeyPrefix, pubkeyPrefix), - ); - - final sortedRepeaters = List.from(_directRepeaters) - ..sort((a, b) => b.snr.compareTo(a.snr)); - final weakestRepeater = sortedRepeaters.isNotEmpty - ? sortedRepeaters.last - : null; - - if (_directRepeaters.length >= 5 && - weakestRepeater != null && - isTracked.isEmpty) { - _directRepeaters.remove(weakestRepeater); - } - - if (isTracked.isNotEmpty) { - final repeater = isTracked.first; - repeater.update(snr); - } else if (_directRepeaters.length < 5) { - _directRepeaters.add( - DirectRepeater( - pubkeyFirstByte: pubkeyFirstByte, - pubkeyPrefix: pubkeyPrefix, - snr: snr, - ), - ); } - notifyListeners(); } void _handleAutoAddConfig(Uint8List frame) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3ded484..6892263 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1151,6 +1151,8 @@ }, "path_enterCustomPath": "Enter Custom Path", "path_currentPathLabel": "Current path", + "path_suggestedRoute": "Suggested route (shortest heard)", + "path_searchRepeaters": "Search repeaters", "path_hexPrefixInstructions": "Enter each hop's hex prefix, separated by commas.", "path_hexPrefixExample": "Tap nodes below, or enter each hop's hex prefix, comma-separated.", "path_labelHexPrefixes": "Path (hex prefixes)", @@ -2310,6 +2312,12 @@ "contacts_ping": "Ping", "contacts_repeaterPathTrace": "Path trace to repeater", "contacts_repeaterPing": "Ping repeater", + "contacts_repeaterPathTraceVia": "Path trace via {prefix}", + "@contacts_repeaterPathTraceVia": { + "placeholders": { + "prefix": { "type": "String" } + } + }, "contacts_roomPathTrace": "Path trace to room server", "contacts_roomPing": "Ping room server", "contacts_chatTraceRoute": "Path trace route", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 620c693..e7c2996 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3831,6 +3831,18 @@ abstract class AppLocalizations { /// **'Current path'** String get path_currentPathLabel; + /// No description provided for @path_suggestedRoute. + /// + /// In en, this message translates to: + /// **'Suggested route (shortest heard)'** + String get path_suggestedRoute; + + /// No description provided for @path_searchRepeaters. + /// + /// In en, this message translates to: + /// **'Search repeaters'** + String get path_searchRepeaters; + /// No description provided for @path_hexPrefixInstructions. /// /// In en, this message translates to: @@ -7000,6 +7012,12 @@ abstract class AppLocalizations { /// **'Ping repeater'** String get contacts_repeaterPing; + /// No description provided for @contacts_repeaterPathTraceVia. + /// + /// In en, this message translates to: + /// **'Path trace via {prefix}'** + String contacts_repeaterPathTraceVia(String prefix); + /// No description provided for @contacts_roomPathTrace. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 88ca5bb..eff0c5f 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -2143,6 +2143,12 @@ class AppLocalizationsBg extends AppLocalizations { @override String get path_currentPathLabel => 'Текущ път'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Въведете 2-символни шестнадесетични префикси за всеки хоп, разделени с кама.'; @@ -4070,6 +4076,11 @@ class AppLocalizationsBg extends AppLocalizations { @override String get contacts_repeaterPing => 'Пингване на повторителя'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Трасиране на път до съ'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 07238d0..214c5f7 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2142,6 +2142,12 @@ class AppLocalizationsDe extends AppLocalizations { @override String get path_currentPathLabel => 'Aktueller Pfad'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Gebe für jeden Zwischen-Hop das 2-stellige Hex-Präfix ein, getrennt durch Kommas.'; @@ -4081,6 +4087,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get contacts_repeaterPing => 'Repeater pingen'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Pfadverfolgung zum Raumserver'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 44d2874..72f9d4e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2102,6 +2102,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get path_currentPathLabel => 'Current path'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Enter each hop\'s hex prefix, separated by commas.'; @@ -4004,6 +4010,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping repeater'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Path trace to room server'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 051baf5..18e416f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2138,6 +2138,12 @@ class AppLocalizationsEs extends AppLocalizations { @override String get path_currentPathLabel => 'Ruta actual'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Introduzca los prefijos hexadecimales de 2 caracteres para cada salto, separados por comas.'; @@ -4069,6 +4075,11 @@ class AppLocalizationsEs extends AppLocalizations { @override String get contacts_repeaterPing => 'Pingar repetidor'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Rastreo de ruta al servidor de la habitación'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index bb18839..4e96d63 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2149,6 +2149,12 @@ class AppLocalizationsFr extends AppLocalizations { @override String get path_currentPathLabel => 'Chemin actuel'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Entrez les préfixes hexadécimaux de 2 caractères pour chaque saut, séparés par des virgules.'; @@ -4092,6 +4098,11 @@ class AppLocalizationsFr extends AppLocalizations { @override String get contacts_repeaterPing => 'Pinguer le répéteur'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Traçage du chemin vers le room server'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 9a11a5d..ab1b306 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -2151,6 +2151,12 @@ class AppLocalizationsHu extends AppLocalizations { @override String get path_currentPathLabel => 'Jelenlegi útvonal'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Adja meg a 2 karakteres hexadecimális előtagokat minden lépéshez, tagolva kommával.'; @@ -4085,6 +4091,11 @@ class AppLocalizationsHu extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping-szinkronizáló'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Kapcsolat a szobai szerverrel'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index cdf047d..2369a1d 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -2140,6 +2140,12 @@ class AppLocalizationsIt extends AppLocalizations { @override String get path_currentPathLabel => 'Percorso corrente'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Inserire i prefissi esadecimali a 2 caratteri per ogni salto, separati da virgole.'; @@ -4074,6 +4080,11 @@ class AppLocalizationsIt extends AppLocalizations { @override String get contacts_repeaterPing => 'Ripetitore ping'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Traccia del percorso al server della stanza'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 410bf26..c559cb2 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2056,6 +2056,12 @@ class AppLocalizationsJa extends AppLocalizations { @override String get path_currentPathLabel => '現在の経路'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => '各ホップに対して、2文字の16進数プレフィックスをカンマで区切って入力してください。'; @@ -3873,6 +3879,11 @@ class AppLocalizationsJa extends AppLocalizations { @override String get contacts_repeaterPing => 'PING 繰り返し'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => '部屋のサーバーへの経路を追跡する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 75d5fad..dbeb88f 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2051,6 +2051,12 @@ class AppLocalizationsKo extends AppLocalizations { @override String get path_currentPathLabel => '현재 경로'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => '각 단계에 대한 2자리 헥사데진 접두사를 쉼표로 구분하여 입력하세요.'; @@ -3875,6 +3881,11 @@ class AppLocalizationsKo extends AppLocalizations { @override String get contacts_repeaterPing => '핑 반복'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => '방 서버로의 경로 추적'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 67128b3..883be8a 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2127,6 +2127,12 @@ class AppLocalizationsNl extends AppLocalizations { @override String get path_currentPathLabel => 'Huidige pad'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Voer 2-letter hex-voorgiffen voor elke hop in, gescheiden door komma\'s.'; @@ -4055,6 +4061,11 @@ class AppLocalizationsNl extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping-repeater'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Padtrace naar room server'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 7fb9be9..5a8fe4c 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -2156,6 +2156,12 @@ class AppLocalizationsPl extends AppLocalizations { @override String get path_currentPathLabel => 'Aktualna ścieżka'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Wprowadź 2-znakowe prefiksy szesnastkowe dla każdego skoku, oddzielone przecinkami.'; @@ -4085,6 +4091,11 @@ class AppLocalizationsPl extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping przekaźnika'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Śledzenie ścieżki do serwera pokojowego'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 004bcd8..616ed3d 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2137,6 +2137,12 @@ class AppLocalizationsPt extends AppLocalizations { @override String get path_currentPathLabel => 'Caminho atual'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Insira os prefixos hexadecimais de 2 caracteres para cada salto, separados por vírgulas.'; @@ -4067,6 +4073,11 @@ class AppLocalizationsPt extends AppLocalizations { @override String get contacts_repeaterPing => 'Pingar repetidor'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Traçar caminho para o servidor da sala'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 38e0b6c..cb046bc 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2144,6 +2144,12 @@ class AppLocalizationsRu extends AppLocalizations { @override String get path_currentPathLabel => 'Текущий маршрут'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Введите 2-символьные шестнадцатеричные префиксы для каждого хопа, разделённые запятыми.'; @@ -4076,6 +4082,11 @@ class AppLocalizationsRu extends AppLocalizations { @override String get contacts_repeaterPing => 'Пинговать повторитель'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Трассировка пути к серверу комнаты'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 0559a95..b15f081 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -2128,6 +2128,12 @@ class AppLocalizationsSk extends AppLocalizations { @override String get path_currentPathLabel => 'Aktuálny priebeh'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Zadajte 2-miestne hexové predpony pre každú fázu, oddelené čiarkami.'; @@ -4051,6 +4057,11 @@ class AppLocalizationsSk extends AppLocalizations { @override String get contacts_repeaterPing => 'Pingovať opakovač'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Sledovanie cesty k serveru miestnosti'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 9e88bc3..b76250b 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -2125,6 +2125,12 @@ class AppLocalizationsSl extends AppLocalizations { @override String get path_currentPathLabel => 'Trenutna pot'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Vnesite 2-karakterne heksadecimalne prefixe za vsako skopo, ločeno z zvezekami.'; @@ -4046,6 +4052,11 @@ class AppLocalizationsSl extends AppLocalizations { @override String get contacts_repeaterPing => 'Pinguj ponavljalnik'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Sledenje poti do strežnika sobe'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index d4ae65b..976bbe5 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -2114,6 +2114,12 @@ class AppLocalizationsSv extends AppLocalizations { @override String get path_currentPathLabel => 'Nuvarande sökväg'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Ange 2-tecknets hex-prefett för varje hopp, åtskilda med komma.'; @@ -4024,6 +4030,11 @@ class AppLocalizationsSv extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping-repeater'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Vägspårning till rumserver'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 8fd8300..f6084f6 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -2138,6 +2138,12 @@ class AppLocalizationsUk extends AppLocalizations { @override String get path_currentPathLabel => 'Поточний шлях'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => 'Введіть 2-символьні hex-префікси для кожного переходу, розділені комами.'; @@ -4078,6 +4084,11 @@ class AppLocalizationsUk extends AppLocalizations { @override String get contacts_repeaterPing => 'Пінгувати ретранслятор'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Трасування шляху до серверу кімнати'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 39e150b..388518b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2015,6 +2015,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get path_currentPathLabel => '当前路径'; + @override + String get path_suggestedRoute => 'Suggested route (shortest heard)'; + + @override + String get path_searchRepeaters => 'Search repeaters'; + @override String get path_hexPrefixInstructions => '请输入每个中继节点的2字符十六进制前缀,用逗号分隔。'; @@ -3776,6 +3782,11 @@ class AppLocalizationsZh extends AppLocalizations { @override String get contacts_repeaterPing => 'Ping 转发节点'; + @override + String contacts_repeaterPathTraceVia(String prefix) { + return 'Path trace via $prefix'; + } + @override String get contacts_roomPathTrace => 'Trace 房间服务器'; diff --git a/lib/main.dart b/lib/main.dart index 5affdd6..b106dd3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'screens/scanner_screen.dart'; import 'services/storage_service.dart'; import 'services/message_retry_service.dart'; import 'services/path_history_service.dart'; +import 'services/mesh_topology_service.dart'; import 'services/app_settings_service.dart'; import 'services/notification_service.dart'; import 'services/ble_debug_log_service.dart'; @@ -41,6 +42,7 @@ void main() async { final storage = StorageService(); final connector = MeshCoreConnector(); final pathHistoryService = PathHistoryService(storage); + final meshTopologyService = MeshTopologyService(); final retryService = MessageRetryService(); final appSettingsService = AppSettingsService(); final bleDebugLogService = BleDebugLogService(); @@ -79,6 +81,7 @@ void main() async { connector.initialize( retryService: retryService, pathHistoryService: pathHistoryService, + topologyService: meshTopologyService, appSettingsService: appSettingsService, translationService: translationService, bleDebugLogService: bleDebugLogService, @@ -100,6 +103,7 @@ void main() async { connector: connector, retryService: retryService, pathHistoryService: pathHistoryService, + meshTopologyService: meshTopologyService, storage: storage, appSettingsService: appSettingsService, bleDebugLogService: bleDebugLogService, @@ -138,6 +142,7 @@ class MeshCoreApp extends StatelessWidget { final MeshCoreConnector connector; final MessageRetryService retryService; final PathHistoryService pathHistoryService; + final MeshTopologyService meshTopologyService; final StorageService storage; final AppSettingsService appSettingsService; final BleDebugLogService bleDebugLogService; @@ -153,6 +158,7 @@ class MeshCoreApp extends StatelessWidget { required this.connector, required this.retryService, required this.pathHistoryService, + required this.meshTopologyService, required this.storage, required this.appSettingsService, required this.bleDebugLogService, @@ -171,6 +177,7 @@ class MeshCoreApp extends StatelessWidget { ChangeNotifierProvider.value(value: connector), ChangeNotifierProvider.value(value: retryService), ChangeNotifierProvider.value(value: pathHistoryService), + ChangeNotifierProvider.value(value: meshTopologyService), ChangeNotifierProvider.value(value: appSettingsService), ChangeNotifierProvider.value(value: bleDebugLogService), ChangeNotifierProvider.value(value: appDebugLogService), diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 13f116a..a1b9c97 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -26,6 +26,7 @@ import '../utils/route_transitions.dart'; import '../widgets/list_filter_widget.dart'; import '../widgets/empty_state.dart'; import '../widgets/quick_switch_bar.dart'; +import '../widgets/path_selection_dialog.dart'; import '../widgets/repeater_login_dialog.dart'; import '../widgets/room_login_dialog.dart'; import '../widgets/sync_progress_overlay.dart'; @@ -1267,31 +1268,100 @@ class _ContactsScreenState extends State ListTile( leading: const Icon(Icons.radar, color: Colors.green), title: Text(context.l10n.contacts_pathTrace), - onTap: () { - final hw = context - .read() - .pathHashByteWidth; - // A flood route is stored as all-zero bytes = "no real route", - // so trace the target directly instead of routing through (and - // sending) an all-zero/empty path. (#150) - final route = contact.pathBytesForDisplay; - final hasRoute = route.isNotEmpty && route.any((b) => b != 0); - Navigator.push( - context, + onTap: () async { + final connector = context.read(); + final navigator = Navigator.of(context); + final l10n = context.l10n; + final hw = connector.pathHashByteWidth; + final hopBytes = PathHelper.traceHopBytes(hw); + + String hopsToHex(List hops) => hops + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + // A committed route is stored non-zero; a flood route is + // all-zero = "no real route". (#150) + final committed = contact.pathBytesForDisplay; + final hasRoute = + committed.isNotEmpty && committed.any((b) => b != 0); + + Uint8List? path; + var flip = false; + var title = l10n.contacts_repeaterPing; + + if (hasRoute) { + // Firmware already committed a route through the mesh. + path = committed; + flip = true; + title = l10n.contacts_repeaterPathTrace; + } else { + // No committed route. Ask the passive topology oracle for a + // route inferred from traffic we've already heard. (#186) + final inferred = connector.topology?.inferRoute( + contact.publicKey, + ); + final direct = + (inferred?.isEmpty ?? false) || + connector.directRepeaters.any( + (r) => r.matchesPathStart(contact.publicKey), + ); + if (direct) { + // A one-hop ping reaches a direct neighbour. + path = Uint8List.fromList( + contact.publicKey.sublist(0, hopBytes), + ); + } else { + // Confirm-first: pre-fill the route builder with the + // inferred route when we have one, else an empty builder. + // The user always confirms — we never fire a silent guess. + // (#186; replaces the old strongest-repeater guess.) + final suggested = + (inferred != null && inferred.isNotEmpty) + ? inferred.map(hopsToHex).join(',') + : null; + String? suggestedLabel; + if (inferred != null && inferred.isNotEmpty) { + // Resolve the inferred hops to repeater/contact NAMES so + // the user can read + verify the route instead of hex. + // (#186) + final names = PathHelper.resolvePathNames( + inferred.expand((h) => h).toList(), + connector.allContacts, + hw, + ); + suggestedLabel = + '${l10n.pathTrace_you} → $names → ${contact.name}'; + } + final picked = await PathSelectionDialog.show( + context, + availableContacts: connector.allContacts + .where( + (c) => c.publicKeyHex != contact.publicKeyHex, + ) + .toList(), + pathHashByteWidth: hw, + initialPath: suggested, + suggestedRouteLabel: suggestedLabel, + title: l10n.contacts_repeaterPathTrace, + ); + if (picked == null || picked.isEmpty) return; // cancelled + path = picked; + flip = true; + final fh = picked.length >= hopBytes + ? picked.sublist(0, hopBytes) + : picked; + title = l10n.contacts_repeaterPathTraceVia( + hopsToHex(fh).toUpperCase(), + ); + } + } + + navigator.push( MaterialPageRoute( builder: (context) => PathTraceMapScreen( - title: hasRoute - ? context.l10n.contacts_repeaterPathTrace - : context.l10n.contacts_repeaterPing, - path: hasRoute - ? route - : Uint8List.fromList( - contact.publicKey.sublist( - 0, - PathHelper.traceHopBytes(hw), - ), - ), - flipPathAround: hasRoute, + title: title, + path: path!, + flipPathAround: flip, targetContact: contact, pathHashByteWidth: hw, ), diff --git a/lib/screens/path_trace_map.dart b/lib/screens/path_trace_map.dart index fe87a98..faaf0a8 100644 --- a/lib/screens/path_trace_map.dart +++ b/lib/screens/path_trace_map.dart @@ -112,12 +112,6 @@ class _PathTraceMapScreenState extends State { bool _showNodeLabels = true; Contact? _targetContact; - String _formatPathPrefixes(Uint8List pathBytes) { - return pathBytes - .map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()) - .join(','); - } - @override void initState() { super.initState(); @@ -279,7 +273,8 @@ class _PathTraceMapScreenState extends State { } appLogger.info( - 'Initiating path trace with path: ${_formatPathPrefixes(path)}', + 'Initiating path trace with path: ' + '${PathHelper.formatPathHex(path, PathHelper.traceHopBytes(widget.pathHashByteWidth))}', tag: 'PathTraceMapScreen', noNotify: !mounted, ); @@ -576,7 +571,7 @@ class _PathTraceMapScreenState extends State { _initialZoom = _points.isNotEmpty ? 13.0 : 2.0; _bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null; _mapKey = ValueKey( - '${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}', + '${context.l10n.pathTrace_you},${PathHelper.formatPathHex(_traceData!.pathData, _traceData!.hopBytes)}', ); _pathDistanceMeters = getPathDistanceMeters(_points); }); diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index ec148c5..3222168 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -290,9 +290,9 @@ class _ScannerScreenState extends State { MeshCoreConnector connector, ScanResult result, ) async { - final name = result.device.platformName.isNotEmpty - ? result.device.platformName - : result.advertisementData.advName; + // Prefer the live advertised name over the cached platformName. (#189) + final adv = result.advertisementData.advName; + final name = adv.isNotEmpty ? adv : result.device.platformName; try { await connector.connect( result.device, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7820913..4563adc 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -19,6 +19,7 @@ import 'settings/message_settings_view.dart'; import 'settings/observer_settings_view.dart'; import 'app_debug_log_screen.dart'; import 'ble_debug_log_screen.dart'; +import 'topology_debug_screen.dart'; import 'companion_radio_stats_screen.dart'; import '../widgets/sync_progress_overlay.dart'; @@ -587,6 +588,23 @@ class _SettingsScreenState extends State { ); }, ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.hub_outlined), + title: const Text('Mesh topology'), + subtitle: const Text( + 'Passively-learned node/edge graph (trace inference)', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const TopologyDebugScreen(), + ), + ); + }, + ), ], ), ); diff --git a/lib/screens/topology_debug_screen.dart b/lib/screens/topology_debug_screen.dart new file mode 100644 index 0000000..cc3a1dd --- /dev/null +++ b/lib/screens/topology_debug_screen.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import '../connector/meshcore_connector.dart'; +import '../connector/meshcore_protocol.dart'; +import '../helpers/snack_bar_builder.dart'; +import '../models/contact.dart'; +import '../services/mesh_topology_service.dart'; +import '../widgets/adaptive_app_bar_title.dart'; + +/// Read-only diagnostic view of the passively-learned mesh topology (#196 / +/// epic #186). It answers one question on real hardware: is +/// [MeshTopologyService] actually ingesting? Node/edge counts climb as routed +/// packets arrive; stuck at zero while traffic flows means the RX ingestion +/// wiring is dead. Strings are hardcoded EN on purpose — this is a dev-facing +/// diagnostic, deliberately not part of the localized UX surface. +class TopologyDebugScreen extends StatelessWidget { + const TopologyDebugScreen({super.key}); + + @override + Widget build(BuildContext context) { + final contacts = context.read().contacts; + return Consumer( + builder: (context, topo, _) { + final now = topo.clockSeconds; + final self = topo.self; + final nodes = topo.nodes.toList() + ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); + final edges = topo.edges.toList() + ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen)); + final hasData = nodes.isNotEmpty || edges.isNotEmpty; + + return Scaffold( + appBar: AppBar( + title: const AdaptiveAppBarTitle('Mesh topology'), + centerTitle: true, + actions: [ + IconButton( + tooltip: 'Copy dump', + icon: const Icon(Icons.copy), + onPressed: !hasData + ? null + : () async { + await Clipboard.setData( + ClipboardData(text: _dump(topo, contacts, now)), + ); + if (!context.mounted) return; + showDismissibleSnackBar( + context, + content: const Text('Topology copied'), + ); + }, + ), + ], + ), + body: SafeArea( + top: false, + child: ListView( + children: [ + _summary(context, topo, self, contacts), + const Divider(height: 1), + if (nodes.isEmpty) + _empty(context) + else ...[ + _sectionHeader(context, 'Nodes (${nodes.length})'), + for (final n in nodes) + _nodeTile(context, n, self, contacts, now), + const Divider(height: 1), + _sectionHeader(context, 'Edges (${edges.length})'), + for (final e in edges) _edgeTile(context, e, contacts, now), + ], + ], + ), + ), + ); + }, + ); + } + + Widget _summary( + BuildContext context, + MeshTopologyService topo, + Uint8List? self, + List contacts, + ) { + final selfLabel = self == null + ? 'not set yet — waiting for first packet' + : (_nameOf(self, contacts) == null + ? _hex(self) + : '${_nameOf(self, contacts)} (${_hex(self)})'); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _stat(context, '${topo.nodeCount}', 'nodes'), + _stat(context, '${topo.edgeCount}', 'edges'), + _stat(context, '${topo.freshnessSeconds ~/ 86400}d', 'freshness'), + ], + ), + const SizedBox(height: 10), + Text( + 'You: $selfLabel', + style: TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: Colors.grey[700], + ), + ), + const SizedBox(height: 6), + Text( + 'Passive map of routed-packet paths. Counts climb as traffic ' + 'arrives; stuck at 0 while messages flow means ingestion is dead.', + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), + ], + ), + ); + } + + Widget _stat(BuildContext context, String value, String label) { + return Expanded( + child: Column( + children: [ + Text( + value, + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[600])), + ], + ), + ); + } + + Widget _sectionHeader(BuildContext context, String text) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + text, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + ), + ); + } + + Widget _nodeTile( + BuildContext context, + TopoNode n, + Uint8List? self, + List contacts, + int now, + ) { + final hex = _hex(n.prefix); + final isSelf = self != null && hex == _hex(self); + final name = isSelf ? 'You' : _nameOf(n.prefix, contacts); + final snr = n.neighbourSnr; + final meta = [ + 'seen ×${n.seenCount}', + _age(n.lastSeen, now), + if (snr != null) 'SNR ${snr.toStringAsFixed(1)}', + ].join(' · '); + return ListTile( + dense: true, + leading: Icon( + isSelf + ? Icons.my_location + : (snr != null ? Icons.cell_tower : Icons.hub_outlined), + size: 18, + color: isSelf + ? Colors.blue + : (snr != null ? Colors.green : Colors.grey), + ), + title: Text( + name ?? hex, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500), + ), + subtitle: Text( + name != null ? '$hex · $meta' : meta, + style: TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Colors.grey[600], + ), + ), + ); + } + + Widget _edgeTile( + BuildContext context, + TopoEdge e, + List contacts, + int now, + ) { + final a = _nameOf(e.a, contacts) ?? _hex(e.a); + final b = _nameOf(e.b, contacts) ?? _hex(e.b); + final failed = e.lastFailed != null; + final meta = [ + '×${e.count}', + _age(e.lastSeen, now), + if (failed) 'failed ${_age(e.lastFailed!, now)}', + ].join(' · '); + return ListTile( + dense: true, + leading: Icon( + failed ? Icons.link_off : Icons.link, + size: 18, + color: failed ? Colors.orange : Colors.grey, + ), + title: Text('$a ~ $b', style: const TextStyle(fontSize: 13)), + subtitle: Text( + meta, + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), + ); + } + + Widget _empty(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), + child: Column( + children: [ + Icon(Icons.hub_outlined, size: 56, color: Colors.grey[400]), + const SizedBox(height: 12), + Text( + 'No topology learned yet', + style: TextStyle(fontSize: 15, color: Colors.grey[600]), + ), + const SizedBox(height: 6), + Text( + 'Listening — this fills as routed packets arrive from the mesh. ' + 'Leave it connected a few minutes.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + ], + ), + ); + } + + // ── pure helpers (also used by the clipboard dump) ────────────────────── + + static String? _nameOf(List prefix, List contacts) { + final matches = contacts + .where( + (c) => + c.publicKey.length >= prefix.length && + _startsWith(c.publicKey, prefix) && + (c.type == advTypeRepeater || c.type == advTypeRoom), + ) + .toList(); + if (matches.isEmpty) return null; + if (matches.length == 1) return matches.first.name; + return matches.map((c) => c.name).join(' | '); + } + + static bool _startsWith(List pubkey, List prefix) { + for (var i = 0; i < prefix.length; i++) { + if (pubkey[i] != prefix[i]) return false; + } + return true; + } + + static String _hex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0').toUpperCase()).join(); + + static String _age(int lastSeen, int now) { + final s = now - lastSeen; + if (s < 0) return 'now'; + if (s < 60) return '${s}s ago'; + if (s < 3600) return '${s ~/ 60}m ago'; + if (s < 86400) return '${s ~/ 3600}h ago'; + return '${s ~/ 86400}d ago'; + } + + static String _dump( + MeshTopologyService topo, + List contacts, + int now, + ) { + final self = topo.self; + final buf = StringBuffer() + ..writeln( + 'Mesh topology — ${topo.nodeCount} nodes, ${topo.edgeCount} edges', + ) + ..writeln('self: ${self == null ? "(unset)" : _hex(self)}') + ..writeln('freshness: ${topo.freshnessSeconds ~/ 86400}d') + ..writeln('') + ..writeln('NODES:'); + for (final n + in topo.nodes.toList() + ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) { + final nm = _nameOf(n.prefix, contacts); + final snr = n.neighbourSnr; + buf.writeln( + ' ${_hex(n.prefix)}${nm != null ? " ($nm)" : ""} ' + 'seen×${n.seenCount} ${_age(n.lastSeen, now)}' + '${snr != null ? " SNR ${snr.toStringAsFixed(1)}" : ""}', + ); + } + buf.writeln('EDGES:'); + for (final e + in topo.edges.toList() + ..sort((a, b) => b.lastSeen.compareTo(a.lastSeen))) { + buf.writeln( + ' ${_hex(e.a)} ~ ${_hex(e.b)} ×${e.count} ' + '${_age(e.lastSeen, now)}${e.lastFailed != null ? " FAILED" : ""}', + ); + } + return buf.toString(); + } +} diff --git a/lib/services/mesh_topology_service.dart b/lib/services/mesh_topology_service.dart new file mode 100644 index 0000000..1a42e23 --- /dev/null +++ b/lib/services/mesh_topology_service.dart @@ -0,0 +1,323 @@ +import 'dart:collection'; + +import 'package:flutter/foundation.dart'; + +/// A node (repeater/relay) in the passively-observed mesh topology, keyed by its +/// width-byte path-hash prefix. (#186 Phase B — see docs/trace-topology-spec.md) +class TopoNode { + final Uint8List prefix; + int lastSeen; // unix seconds + /// Set ONLY for our own direct neighbours (the last hop of a packet we heard), + /// where we actually measured the reception SNR. Null for deeper hops, whose + /// link quality we can't observe from here. + double? neighbourSnr; + int seenCount; + + TopoNode({ + required this.prefix, + required this.lastSeen, + this.neighbourSnr, + this.seenCount = 1, + }); +} + +/// An undirected observed adjacency between two nodes ("seen consecutive in a +/// packet's path"). Stored once, canonically keyed. +class TopoEdge { + final Uint8List a; + final Uint8List b; + int lastSeen; + int count; + + /// Set when a trace routed over this link timed out — inference deprioritises + /// it so the graph self-heals as topology drifts. + int? lastFailed; + + TopoEdge({ + required this.a, + required this.b, + required this.lastSeen, + this.count = 1, + this.lastFailed, + }); +} + +/// Accumulates the routing paths of received packets into a topology graph and +/// infers trace routes from it — the thing the firmware can't do (it only walks +/// a caller-supplied route) and the reference apps push onto the user (manual +/// build). Deliberately SEPARATE from PathHistoryService (Ben, 2026-07-05): +/// PathHistory weights message routing; this is the trace oracle. +/// +/// Storage is bounded by design: the graph is deduplicated (a node/edge stored +/// once, refreshed), pruned by freshness, and hard-capped by node count — so it +/// scales with mesh size, not hop depth or packet volume. (#186) +class MeshTopologyService extends ChangeNotifier { + static const int defaultFreshnessSeconds = 7 * 24 * 3600; // 7 days + static const int defaultMaxNodes = 2000; + + final int freshnessSeconds; + final int maxNodes; + final int Function() _now; + + final Map _nodes = {}; + final Map _edges = {}; + final Map> _adj = {}; // nodeHex -> neighbour nodeHex set + + Uint8List? _self; // our own node prefix (width bytes) + + MeshTopologyService({ + int? freshnessSeconds, + int? maxNodes, + int Function()? now, + }) : freshnessSeconds = freshnessSeconds ?? defaultFreshnessSeconds, + maxNodes = maxNodes ?? defaultMaxNodes, + _now = now ?? _defaultNow; + + static int _defaultNow() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + + int get nodeCount => _nodes.length; + int get edgeCount => _edges.length; + + /// Our own node prefix (the BFS origin), or null until the first RX set it. + Uint8List? get self => _self; + + /// The service's clock in unix seconds, so a debug/inspection UI computes + /// last-heard ages against the same clock the graph is stamped with. + int get clockSeconds => _now(); + + /// Read-only snapshot of learned nodes, for debug/inspection UIs only. + List get nodes => List.unmodifiable(_nodes.values); + + /// Read-only snapshot of observed edges, for debug/inspection UIs only. + List get edges => List.unmodifiable(_edges.values); + + bool hasNode(List prefix) => _nodes.containsKey(_hex(prefix)); + + /// The measured reception SNR for a node, set only when it was our direct + /// neighbour (the last hop of a packet we heard); null for deeper hops. + double? neighbourSnrOf(List prefix) => + _nodes[_hex(prefix)]?.neighbourSnr; + + /// Our own node prefix — the BFS origin for inference and the anchor for the + /// `us ~ direct-neighbour` edges. + void setSelf(List prefix, int width) { + final w = width < 1 ? 1 : width; + _self = Uint8List.fromList( + prefix.sublist(0, w > prefix.length ? prefix.length : w), + ); + } + + static String _hex(List b) => + b.map((x) => x.toRadixString(16).padLeft(2, '0')).join(); + + static String _edgeKey(String x, String y) => + x.compareTo(y) <= 0 ? '$x|$y' : '$y|$x'; + + /// Ingests one received packet's routing path (as received: + /// `origin → h1 → … → hk → us`, so `hk` = `path`'s last hop = our direct + /// neighbour). [lastHopSnr] is the SNR of our reception (attributed to `hk`). + /// Returns true on a notify-worthy STRUCTURAL change (new node/edge). Cheap: + /// O(path length ≤ 64) map writes — the path was already parsed upstream. + bool observePath( + List path, { + required int width, + double? lastHopSnr, + int? ts, + }) { + final w = width < 1 ? 1 : width; + if (path.length < w) return false; + final now = ts ?? _now(); + + final hops = []; + for (var i = 0; i + w <= path.length; i += w) { + hops.add(Uint8List.fromList(path.sublist(i, i + w))); + } + if (hops.isEmpty) return false; + + var changed = false; + for (var i = 0; i < hops.length; i++) { + final isLastHop = i == hops.length - 1; // our direct neighbour + changed |= _upsertNode(hops[i], now, isLastHop ? lastHopSnr : null); + } + for (var i = 0; i + 1 < hops.length; i++) { + changed |= _upsertEdge(hops[i], hops[i + 1], now); + } + final self = _self; + if (self != null && self.length >= w) { + changed |= _upsertNode(self, now, null); + changed |= _upsertEdge(self, hops.last, now); // us ~ last hop + } + + _enforceCap(); + if (changed) notifyListeners(); + return changed; + } + + bool _upsertNode(Uint8List prefix, int now, double? neighbourSnr) { + final k = _hex(prefix); + final existing = _nodes[k]; + if (existing == null) { + _nodes[k] = TopoNode( + prefix: Uint8List.fromList(prefix), + lastSeen: now, + neighbourSnr: neighbourSnr, + ); + _adj.putIfAbsent(k, () => {}); + return true; // structural growth + } + existing.lastSeen = now; + existing.seenCount++; + if (neighbourSnr != null) existing.neighbourSnr = neighbourSnr; + return false; // refresh only + } + + bool _upsertEdge(Uint8List a, Uint8List b, int now) { + final ha = _hex(a); + final hb = _hex(b); + if (ha == hb) return false; // no self-loops + final k = _edgeKey(ha, hb); + final existing = _edges[k]; + if (existing == null) { + _edges[k] = TopoEdge( + a: Uint8List.fromList(a), + b: Uint8List.fromList(b), + lastSeen: now, + ); + _adj.putIfAbsent(ha, () => {}).add(hb); + _adj.putIfAbsent(hb, () => {}).add(ha); + return true; + } + existing.lastSeen = now; + existing.count++; + return false; + } + + /// The ordered intermediate hops from us to [target], EXCLUDING both us and + /// the target — feed straight into + /// `PathHelper.buildTraceRoundTrip(routingPath: result, targetPrefix: target)`. + /// + /// - `[]` → target is a direct neighbour (a one-hop ping is correct). + /// - `null` → target isn't reachable in the FRESH graph (→ manual builder). + /// + /// Shortest hop count over edges within the freshness window, biased at each + /// step toward stronger first-hop SNR / fresher / more-seen links. (Phase B + /// uses ordered-BFS; a weighted search is a later refinement.) + List? inferRoute(List target, {int? nowTs}) { + final self = _self; + if (self == null) return null; + final now = nowTs ?? _now(); + final selfHex = _hex(self); + final targetHex = _hex(target); + if (targetHex == selfHex) return []; + if (!_nodes.containsKey(targetHex)) return null; // never observed + + final visited = {selfHex}; + final parent = {}; + final queue = Queue()..add(selfHex); + while (queue.isNotEmpty) { + final cur = queue.removeFirst(); + if (cur == targetHex) break; + for (final nb in _freshNeighbours(cur, now, selfHex)) { + if (visited.add(nb)) { + parent[nb] = cur; + queue.add(nb); + } + } + } + if (!visited.contains(targetHex)) return null; + + final chain = []; + var node = targetHex; + while (node != selfHex) { + chain.add(node); + final p = parent[node]; + if (p == null) return null; + node = p; + } + // chain = [target, …, n1]; reverse and drop self(implicit)+target ends. + final ordered = chain.reversed.toList(); // [n1, …, target] + if (ordered.isEmpty) return []; + final intermediates = ordered.sublist(0, ordered.length - 1); // drop target + return intermediates.map((h) => _nodes[h]!.prefix).toList(); + } + + List _freshNeighbours(String nodeHex, int now, String selfHex) { + final adj = _adj[nodeHex]; + if (adj == null || adj.isEmpty) return const []; + final out = []; + for (final nb in adj) { + final e = _edges[_edgeKey(nodeHex, nb)]; + if (e == null) continue; + if (now - e.lastSeen > freshnessSeconds) continue; // stale link + out.add(nb); + } + final isSelfRing = nodeHex == selfHex; + out.sort((x, y) { + if (isSelfRing) { + final sx = _nodes[x]?.neighbourSnr ?? -1e9; + final sy = _nodes[y]?.neighbourSnr ?? -1e9; + if (sx != sy) return sy.compareTo(sx); // stronger first hop first + } + final ex = _edges[_edgeKey(nodeHex, x)]!; + final ey = _edges[_edgeKey(nodeHex, y)]!; + // Deprioritise links a trace recently failed over, so inference self-heals + // as topology drifts; failures decay out of the freshness window. (#186) + final fx = + (ex.lastFailed != null && now - ex.lastFailed! < freshnessSeconds) + ? 1 + : 0; + final fy = + (ey.lastFailed != null && now - ey.lastFailed! < freshnessSeconds) + ? 1 + : 0; + if (fx != fy) return fx.compareTo(fy); + final byFresh = ey.lastSeen.compareTo(ex.lastSeen); + if (byFresh != 0) return byFresh; + return ey.count.compareTo(ex.count); + }); + return out; + } + + /// Flags every link on a failed route so inference deprioritises it next time. + void markRouteFailed(List> hopChain, {int? ts}) { + final now = ts ?? _now(); + for (var i = 0; i + 1 < hopChain.length; i++) { + final e = _edges[_edgeKey(_hex(hopChain[i]), _hex(hopChain[i + 1]))]; + if (e != null) e.lastFailed = now; + } + } + + /// Drops nodes/edges not heard within the freshness window — for persistence + /// bounds (Phase D). Inference already ignores stale links at query time. + void pruneStale([int? nowTs]) { + final now = nowTs ?? _now(); + final dead = _nodes.entries + .where((e) => now - e.value.lastSeen > freshnessSeconds) + .map((e) => e.key) + .toList(); + for (final k in dead) { + _removeNode(k); + } + } + + void _enforceCap() { + if (_nodes.length <= maxNodes) return; + final byAge = _nodes.entries.toList() + ..sort((a, b) => a.value.lastSeen.compareTo(b.value.lastSeen)); + final removeCount = _nodes.length - maxNodes; + for (var i = 0; i < removeCount; i++) { + _removeNode(byAge[i].key); + } + } + + void _removeNode(String hex) { + _nodes.remove(hex); + final adj = _adj.remove(hex); + if (adj != null) { + for (final nb in adj) { + _adj[nb]?.remove(hex); + _edges.remove(_edgeKey(hex, nb)); + } + } + } +} diff --git a/lib/widgets/device_tile.dart b/lib/widgets/device_tile.dart index 3f30b38..f59e1a4 100644 --- a/lib/widgets/device_tile.dart +++ b/lib/widgets/device_tile.dart @@ -14,9 +14,11 @@ class DeviceTile extends StatelessWidget { Widget build(BuildContext context) { final device = scanResult.device; final rssi = scanResult.rssi; - final name = device.platformName.isNotEmpty - ? device.platformName - : scanResult.advertisementData.advName; + // Prefer the LIVE advertised name over the OS-cached platformName, which can + // be a stale/default cache (e.g. "nimble", the NimBLE stack default) that + // masks the device's real advertised name. (#189) + final adv = scanResult.advertisementData.advName; + final name = adv.isNotEmpty ? adv : device.platformName; return ListTile( leading: _buildSignalIcon(rssi), diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index c3b430b..62a2c6b 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -14,6 +14,11 @@ class PathSelectionDialog extends StatefulWidget { final VoidCallback? onRefresh; final int pathHashByteWidth; + /// A human-readable, name-resolved rendering of a pre-filled suggested route + /// (e.g. "You → HomeRepeater → Backbone → Target"), shown prominently so the + /// user can read/verify the inferred route instead of raw hex. (#186) + final String? suggestedRouteLabel; + const PathSelectionDialog({ super.key, required this.availableContacts, @@ -22,6 +27,7 @@ class PathSelectionDialog extends StatefulWidget { this.initialPath, this.currentPathLabel, this.onRefresh, + this.suggestedRouteLabel, }); @override @@ -35,6 +41,7 @@ class PathSelectionDialog extends StatefulWidget { String? initialPath, String? currentPathLabel, VoidCallback? onRefresh, + String? suggestedRouteLabel, }) { return showDialog( context: context, @@ -45,6 +52,7 @@ class PathSelectionDialog extends StatefulWidget { initialPath: initialPath, currentPathLabel: currentPathLabel, onRefresh: onRefresh, + suggestedRouteLabel: suggestedRouteLabel, ), ); } @@ -81,6 +89,7 @@ class _PathSelectionDialogState extends State { late TextEditingController _controller; final List _selectedContacts = []; List _validContacts = []; + String _search = ''; // Hop prefix width in bytes (clamped >= 1) and in hex chars, from the device's // configured path-hash width. (#155) @@ -104,9 +113,22 @@ class _PathSelectionDialogState extends State { } void _filterValidContacts() { - _validContacts = widget.availableContacts - .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom) - .toList(); + // Repeaters/rooms only, filtered by the search box, sorted by name so the + // list is coherent and findable instead of raw contact-store order. (#186) + final q = _search.trim().toLowerCase(); + _validContacts = + widget.availableContacts + .where((c) => c.type == advTypeRepeater || c.type == advTypeRoom) + .where( + (c) => + q.isEmpty || + c.name.toLowerCase().contains(q) || + c.publicKeyHex.toLowerCase().startsWith(q), + ) + .toList() + ..sort( + (a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()), + ); } void _updateTextFromContacts() { @@ -205,6 +227,54 @@ class _PathSelectionDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (widget.suggestedRouteLabel != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.route, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + const SizedBox(width: 6), + Text( + l10n.path_suggestedRoute, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + widget.suggestedRouteLabel!, + style: TextStyle( + fontSize: 13, + color: Theme.of( + context, + ).colorScheme.onSecondaryContainer, + ), + ), + ], + ), + ), + ], if (widget.currentPathLabel != null) ...[ Row( children: [ @@ -272,6 +342,19 @@ class _PathSelectionDialogState extends State { ], ), const SizedBox(height: 8), + TextField( + onChanged: (v) => setState(() { + _search = v; + _filterValidContacts(); + }), + decoration: InputDecoration( + isDense: true, + prefixIcon: const Icon(Icons.search, size: 18), + hintText: l10n.path_searchRepeaters, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), if (_validContacts.isEmpty) ...[ Center( child: Padding( diff --git a/pubspec.yaml b/pubspec.yaml index a888108..05393a4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.2-rc.1+51 +version: 1.1.2-rc.1+46 environment: sdk: ^3.9.2 diff --git a/test/connector/direct_repeater_ranking_test.dart b/test/connector/direct_repeater_ranking_test.dart index eea67cc..1f712de 100644 --- a/test/connector/direct_repeater_ranking_test.dart +++ b/test/connector/direct_repeater_ranking_test.dart @@ -112,4 +112,86 @@ void main() { expect(repeater([0x84]).matchesPathStart([]), isFalse); }); }); + + group( + 'DirectRepeater.recordHop (#150 — RX-fed nearest-repeater detection)', + () { + DirectRepeater rep(List prefix, double snr) => DirectRepeater( + pubkeyFirstByte: prefix.last, + pubkeyPrefix: Uint8List.fromList(prefix), + snr: snr, + ); + + test('adds a new hop to an empty list', () { + final list = []; + final changed = DirectRepeater.recordHop( + list, + Uint8List.fromList([0x6a, 0x3d]), + 5.0, + ); + expect(changed, isTrue); + expect(list.length, 1); + expect(list.single.pubkeyPrefix, [0x6a, 0x3d]); + expect(list.single.snr, 5.0); + }); + + test('an empty prefix is ignored', () { + final list = []; + expect(DirectRepeater.recordHop(list, Uint8List(0), 5.0), isFalse); + expect(list, isEmpty); + }); + + test('refreshes an existing hop; notifies only on a >= 1 dB move', () { + final list = [ + rep([0x6a, 0x3d], 5.0), + ]; + // Sub-threshold jitter: value refreshes silently (no notify). + expect( + DirectRepeater.recordHop(list, Uint8List.fromList([0x6a, 0x3d]), 5.5), + isFalse, + ); + expect(list.single.snr, 5.5); + expect(list.length, 1); + // >= 1 dB move: worth a notify. + expect( + DirectRepeater.recordHop(list, Uint8List.fromList([0x6a, 0x3d]), 7.0), + isTrue, + ); + expect(list.single.snr, 7.0); + }); + + test('at cap, a stronger newcomer evicts the weakest', () { + final list = [ + rep([0x01, 0x01], 1.0), + rep([0x02, 0x02], 2.0), + rep([0x03, 0x03], 3.0), + rep([0x04, 0x04], 4.0), + rep([0x05, 0x05], 5.0), + ]; + expect( + DirectRepeater.recordHop(list, Uint8List.fromList([0x06, 0x06]), 6.0), + isTrue, + ); + expect(list.length, 5); + expect(list.any((r) => r.snr == 1.0), isFalse); // weakest evicted + expect(list.any((r) => r.pubkeyPrefix[0] == 0x06), isTrue); + }); + + test('at cap, a newcomer no stronger than the weakest is dropped', () { + final list = [ + rep([0x01, 0x01], 1.0), + rep([0x02, 0x02], 2.0), + rep([0x03, 0x03], 3.0), + rep([0x04, 0x04], 4.0), + rep([0x05, 0x05], 5.0), + ]; + expect( + DirectRepeater.recordHop(list, Uint8List.fromList([0x06, 0x06]), 0.5), + isFalse, + ); + expect(list.length, 5); + expect(list.any((r) => r.pubkeyPrefix[0] == 0x06), isFalse); + }); + }, + ); } diff --git a/test/helpers/path_helper_test.dart b/test/helpers/path_helper_test.dart index 3d4f61d..7a586d5 100644 --- a/test/helpers/path_helper_test.dart +++ b/test/helpers/path_helper_test.dart @@ -146,6 +146,24 @@ void main() { ); }); + test( + 'buildTraceRoundTrip — via-repeater: [via]+target → [via,target,via]', + () { + // #150: with no committed route, trace a repeater through our strongest + // direct repeater as the first hop (Ben's mesh: 6A3D fronting AC01). + expect( + PathHelper.buildTraceRoundTrip( + routingPath: [0x6a, 0x3d], + routingWidth: 2, + hopBytes: 2, + throughTarget: true, + targetPrefix: [0xac, 0x01], + ), + [0x6a, 0x3d, 0xac, 0x01, 0x6a, 0x3d], + ); + }, + ); + test('buildTraceRoundTrip — chat: far hop is the turnaround', () { expect( PathHelper.buildTraceRoundTrip( diff --git a/test/services/mesh_topology_service_test.dart b/test/services/mesh_topology_service_test.dart new file mode 100644 index 0000000..0de001d --- /dev/null +++ b/test/services/mesh_topology_service_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/services/mesh_topology_service.dart'; + +/// #186 Phase B — the passive-topology route oracle. +/// Path orientation used throughout: a received path is `origin → h1 → … → hk → us`, +/// so `hk` (the path's LAST hop) is our direct neighbour. +void main() { + // 2-byte path-hash width fixtures. + const self = [0x00, 0x01]; + const r1 = [0x11, 0x11]; + const r2 = [0x22, 0x22]; + const r3 = [0x33, 0x33]; + const t = [0xCC, 0xCC]; // target — only ever seen relaying + const u = [0x99, 0x99]; // never observed + + List path(List> hops) => hops.expand((h) => h).toList(); + List>? asLists(List? r) => + r?.map((u) => (u as List).cast()).toList(); + + MeshTopologyService svc({int? maxNodes}) { + final s = MeshTopologyService(maxNodes: maxNodes); + s.setSelf(self, 2); + return s; + } + + group('observePath — orientation + graph growth', () { + test('last hop is our neighbour (SNR); mids are relays (no SNR)', () { + final s = svc(); + // packet: origin → R2 → T → R1 → us (R1 = neighbour, T relayed) + final changed = s.observePath( + path([r2, t, r1]), + width: 2, + lastHopSnr: 6.5, + ); + expect(changed, isTrue); + expect(s.nodeCount, 4); // self, R2, T, R1 + expect(s.edgeCount, 3); // R2~T, T~R1, self~R1 + expect(s.neighbourSnrOf(r1), 6.5); // last hop = measured neighbour + expect(s.neighbourSnrOf(t), isNull); // relayed — no measurable SNR + expect(s.neighbourSnrOf(r2), isNull); + }); + + test('re-observing the same path is a silent refresh (no growth)', () { + final s = svc(); + s.observePath(path([r2, t, r1]), width: 2); + final n = s.nodeCount, e = s.edgeCount; + final changed = s.observePath(path([r2, t, r1]), width: 2); + expect(changed, isFalse); + expect(s.nodeCount, n); + expect(s.edgeCount, e); + }); + }); + + group('inferRoute', () { + test('direct neighbour → empty route (one-hop ping is correct)', () { + final s = svc(); + s.observePath(path([r1]), width: 2); // us ~ R1 + expect(asLists(s.inferRoute(r1)), isEmpty); + }); + + test('KEY: routes to a node only ever heard RELAYING', () { + final s = svc(); + // T never arrives as a last hop — only mid-path — yet we can reach it. + s.observePath(path([r2, t, r1]), width: 2); // origin→R2→T→R1→us + expect(s.neighbourSnrOf(t), isNull); // proof T was never our neighbour + // us → R1 → T + expect(asLists(s.inferRoute(t)), [r1]); + }); + + test('unobserved target → null (→ manual builder)', () { + final s = svc(); + s.observePath(path([r2, t, r1]), width: 2); + expect(s.inferRoute(u), isNull); + }); + + test('picks the shorter of two known routes', () { + final s = svc(); + s.observePath(path([t, r1]), width: 2); // us→R1→T (2 hops) + s.observePath(path([t, r3, r2]), width: 2); // us→R2→R3→T (3 hops) + expect(asLists(s.inferRoute(t)), [r1]); + }); + }); + + group('freshness + bounds', () { + test('a stale-only route is not inferred', () { + const now = 1000000000; + const eightDays = 8 * 24 * 3600; + final s = svc(); + s.observePath(path([t, r1]), width: 2, ts: now - eightDays); // stale + expect(s.inferRoute(t, nowTs: now), isNull); // stale link excluded + // a fresh observation of the same link makes it routable again + s.observePath(path([t, r1]), width: 2, ts: now); + expect(asLists(s.inferRoute(t, nowTs: now)), [r1]); + }); + + test('node count is hard-capped (oldest evicted)', () { + final s = svc(maxNodes: 3); + s.observePath(path([r1]), width: 2, ts: 100); // self, R1 + s.observePath(path([r2]), width: 2, ts: 200); // + R2 → 3 + s.observePath(path([r3]), width: 2, ts: 300); // + R3 → 4 → evict oldest + expect(s.nodeCount, 3); + expect(s.hasNode(r1), isFalse); // R1 was the oldest + expect(s.hasNode(r3), isTrue); + }); + + test('pruneStale drops nodes past the freshness window', () { + const now = 1000000000; + final s = svc(); + s.observePath(path([r2, t, r1]), width: 2, ts: now - 8 * 24 * 3600); + expect(s.nodeCount, greaterThan(0)); + s.pruneStale(now); + expect(s.nodeCount, 0); + }); + }); +} diff --git a/untranslated.json b/untranslated.json index 068a04a..f14e302 100644 --- a/untranslated.json +++ b/untranslated.json @@ -20,6 +20,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -44,6 +58,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -68,6 +96,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -92,6 +134,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -116,6 +172,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -140,6 +210,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -164,6 +248,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -188,6 +286,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -212,6 +324,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -236,6 +362,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -260,6 +400,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -284,6 +438,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -308,6 +476,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -332,6 +514,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -356,6 +552,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -380,6 +590,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ], @@ -404,6 +628,20 @@ "appSettings_clock24h", "chat_replyWithRoute", "channel_heardTooltip", + "path_suggestedRoute", + "path_searchRepeaters", + "channels_shareQr", + "channels_shareQrTitle", + "channels_shareQrInstructions", + "channels_scanQr", + "channels_scanQrDesc", + "channels_scanQrInstructions", + "channels_invalidQr", + "channels_noFreeSlot", + "channels_qrAddConfirm", + "channels_qrAdded", + "channels_qrExists", + "contacts_repeaterPathTraceVia", "snrIndicator_noNeighbors" ] }