Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:
protocol_version — CAN these builds talk. The correctness axis. Below 1.0.0
the minor is the breaking axis, per SemVer §4.
release window — MAY they, per policy. [min, max), advertised in the
profile, overridable by the operator.
The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.
Three rules that shape the rest:
- GET /v1/profile is never gated. It is where a refused client learns which
version it needs; gating it leaves the user with a network error instead of
an answer, which is precisely the confusion this exists to remove.
- An unparseable or absent version is "unknown", and is allowed. Development
builds report "dev", and a client too old to send the header cannot be
identified anyway.
- Bounds sit at breaking boundaries, not at releases, so shipping a patch
never requires editing a range. The app's server minimum is 0.4.2 for a
stated reason: earlier multi-homed servers mis-addressed granted sends and
the client measured 100% downstream loss that never happened.
The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
165 lines
11 KiB
Markdown
165 lines
11 KiB
Markdown
# Echolot — context for Claude Code
|
|
|
|
This repo is the **capability prober** for the Echolot project: an F/OSS Android app for detecting
|
|
and debugging local network issues (target audience: network engineers). The prober validates the
|
|
no-root feasibility matrix on real hardware before the production app is built.
|
|
|
|
## Project facts
|
|
|
|
- **Name:** Echolot. Domain echo-lot.app. URI scheme `echolot://`. Namespace `app.echo_lot.*`
|
|
(hyphen→underscore; appIds/packages can't contain hyphens). Prober appId: `app.echo_lot.prober`.
|
|
- **Stack:** native Kotlin + Jetpack Compose. No Flutter (all value is in platform APIs;
|
|
Flutter would be a Dart skin over a full Kotlin app + F-Droid build friction).
|
|
- **Tiers:** `app` (no root), `shizuku` (ADB-shell privileges via wireless pairing, shipped in v1),
|
|
`root` (future module). Every result records which tier produced it.
|
|
- **License:** code is **GPL-3.0-or-later** (`LICENSE`), the specs in `docs/` are **CC-BY-4.0**
|
|
(`docs/LICENSE`). New source files get the two-line SPDX header the existing ones carry.
|
|
The server, when it lands, is GPL too — not AGPL (see `docs/build-status.md` for the reasoning).
|
|
|
|
## Design docs (source of truth, in `docs/`)
|
|
|
|
- `docs/feature-catalog-and-feasibility.md` — full feature list + no-root feasibility matrix.
|
|
- `docs/measurement-schema.md` — the archived/exportable measurement JSON format (observation vs
|
|
finding separation, two-clock rule, columnar trains, anonymization types).
|
|
- `docs/probe-protocol.md` — client↔server wire protocol (pinned-TLS control plane, binary UDP data
|
|
plane with anti-amplification HMAC, STUN, canary DNS reference records).
|
|
- `docs/build-status.md` — running log of decisions, what is delivered, and the next steps.
|
|
|
|
The three specs are draft-complete and user-reviewed; treat them as the contract. `build-status.md`
|
|
is the mutable one — update it as work lands.
|
|
|
|
Keep prober result IDs aligned with the measurement-schema test-type registry.
|
|
|
|
## Versioning
|
|
|
|
Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental
|
|
work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are
|
|
namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI →
|
|
binaries + Gitea release + registry image; the server on fmr can `--self-update` from those
|
|
releases.
|
|
|
|
The app's version lives once, as `appVersionName` in `app/build.gradle.kts`; **`versionCode` is
|
|
derived from it** (`major*1e6 + minor*1e4 + patch*10`). Never set it by hand — a second number a
|
|
human has to remember to bump eventually disagrees with the first.
|
|
|
|
**Versions are load-bearing** (probe-protocol.md §8): the server refuses apps outside its window
|
|
with `426`, and the app refuses servers outside its own. Two axes, kept separate:
|
|
- `protocol_version` — *can* they talk. The correctness axis; below 1.0.0 the **minor** is the
|
|
breaking axis.
|
|
- release-version window — *may* they, per policy. `[min, max)`, bounds at breaking boundaries so
|
|
a patch never strands a fleet. Client bounds: `Compat.kt`. Server: `ECHOLOT_MIN/MAX_APP_VERSION`.
|
|
|
|
Raise a minimum only when older peers are actively harmful, and say why in the constant's comment.
|
|
`GET /v1/profile` must stay ungated — it is how a refused client learns what it needs.
|
|
|
|
## Layout
|
|
|
|
Monorepo. The prober is one deliverable; the Go server and the production app land as siblings.
|
|
|
|
```
|
|
docs/ design docs (above) — apply repo-wide, not just to the prober
|
|
echolot-prober/ the capability prober (self-contained Gradle build)
|
|
app/src/main/java/app/echo_lot/prober/
|
|
MainActivity.kt Compose host, runs probes sequentially
|
|
probe/ Probe interface + all app-tier probes + OS ABI helper
|
|
shizuku/ AIDL UserService + ShizukuRunner (shell command exec)
|
|
export/Report.kt JSON report + share intent
|
|
ui/ProberScreen.kt result cards colored by verdict
|
|
```
|
|
|
|
## Client modules (echolot-app/)
|
|
|
|
Pure Kotlin/JVM where possible, so the interesting logic is unit-testable without a device and can
|
|
be exercised against the live server from the PC:
|
|
|
|
- `core-protocol` — control plane (pinned TLS) + ELT1 UDP data plane. **One `ProbeSession` per
|
|
server session, for its whole lifetime**: a second one restarts sequence numbers, the server's
|
|
anti-replay window discards every packet, and granted sends then target the closed socket.
|
|
- `core-measurement` — the schema types. `core-engine` — composes probes into documents.
|
|
- `core-privacy` — the §8 anonymizer (`full` / `balanced` / `strict`). Field classification lives
|
|
in one table (`Classification.kt`); keep it there rather than annotating models.
|
|
- `core-archive` — on-device run storage + retention. `enabled` is separate from the three
|
|
ceilings: all-zeros means "no limits", not "keep nothing".
|
|
|
|
**The local archive keeps the unredacted document; anonymization happens per upload, on the way
|
|
out.** Never redact what is stored locally.
|
|
|
|
### Live testing without a device
|
|
`echolot-app/scripts/test-fmr.sh [gradle-task] [test-filter]` mints an enrollment token over SSH,
|
|
enrolls, computes the SPKI pin from the served cert and runs a `Live*Test` against fmr. This covers
|
|
the whole server-facing vertical (granted sends, downstream MTU, uploads) with no phone involved —
|
|
use it before asking the user to test on hardware.
|
|
|
|
## Conventions
|
|
|
|
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway).
|
|
- Uncertain OS API paths (getsockoptInt, recvmsg, nat64Prefix) are reached via reflection/runCatching
|
|
and reported, not assumed — the prober's purpose is to discover what exists.
|
|
- Hardcoded Linux sockopt ABI numbers live in `OsAbi.kt` with rationale; do not "fix" them to
|
|
OsConstants names that don't exist.
|
|
|
|
## Build
|
|
|
|
From `echolot-prober/`: `./gradlew :app:assembleDebug` (needs local Android SDK; set `sdk.dir` in
|
|
`echolot-prober/local.properties`). The Gradle build is rooted there, not at the repo root.
|
|
First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
|
|
|
|
## Working with test devices (hard-won lessons)
|
|
|
|
- **Prefer USB for adb.** Wireless debugging dies constantly: probe runs churn the wifi the
|
|
debug link rides on, the Lenovo tablet's ZUI power management kills the listener anyway, and
|
|
the port rotates on every restart. Never drive a probe run over *wireless* adb.
|
|
- Collection loop over USB, fully driveable by Claude: install → `am start` → tap "Run all
|
|
probes" → poll `uiautomator dump` until the button label returns to "Run all probes" → tap
|
|
"Export JSON" → the report lands in `cache/reports/` and comes back via
|
|
`adb shell run-as app.echo_lot.prober cat …` (no need to drive the share sheet; dismiss it
|
|
with BACK). Archive under `echolot-prober/reports/`, record findings in `docs/build-status.md`.
|
|
- Bash-tool `adb shell` calls with absolute device paths get mangled by Git Bash path
|
|
conversion (`/data/…` → `C:/Program Files/Git/data/…`); use the PowerShell tool for those.
|
|
- Bump `versionCode` on every deployed prober change — it shows on screen and as `proberBuild`
|
|
in the report; that's how a report is matched to a build.
|
|
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
|
|
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
|
it. Full history in build-status.md.
|
|
- **Wireless-adb beacon** (`echolot-app/adb-beacon` + `tools/adb-beacon/receiver.py`): the
|
|
wireless-debug port rotates every couple of minutes on these devices; the beacon reads the live
|
|
port from adbd's own mDNS (`_adb-tls-connect._tcp`, filtered to the device's own IP) and POSTs
|
|
it to fmr:443 (cleartext allowed; over a validated net); a PC connector polls and reconnects.
|
|
Bootstrap needs one manual adb connection to install it, then it self-heals. Existing adb
|
|
connections survive port rotation — only fresh connects need the new port.
|
|
**Resolve adbd's mDNS advertisement exactly ONCE per service instance** (re-resolving makes
|
|
adbd re-arm the connection, which spams "wireless debugging connected" notifications); the
|
|
periodic heartbeat only re-POSTs the cached port. Rotation is still caught: onServiceLost
|
|
clears the guard, so the new advertisement is resolved once and reported within seconds
|
|
(verified: 37089 -> 33667 reported 6 s after rotation).
|
|
**Known limitation — do not run the beacon on the OnePlus.** On network churn (SSID jump, roam)
|
|
adbd repeatedly drops and re-publishes its advertisement, so lost/found cycles keep clearing the
|
|
resolve-guard; each resolve makes adbd re-arm and post a "wireless debugging connected"
|
|
notification. Guarding reduces but cannot eliminate this — resolving adbd's own mDNS record is
|
|
inherently noisy on that device. Use manual `ip:port` there (ask the user), or read the port via
|
|
Shizuku (`ss -tlnp | grep adbd`, no mDNS) if the shell tier is available.
|
|
- **Shizuku start kills the adb bridge and the beacon can't auto-recover it.** Shizuku's non-root
|
|
start pairs over wireless debugging and runs its starter through adb, hijacking the channel:
|
|
adb goes device→offline→refused, and adbd keeps advertising the now-dead port over mDNS (stale),
|
|
so the beacon reports a port that no longer accepts connections. Workaround: after starting
|
|
Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh
|
|
port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
|
|
around this (or USB, if ever available).
|
|
- **Empty-jar race with the IDE.** VSCodium's Java/Kotlin extension runs its own Gradle daemon on
|
|
the same project; when it overlaps a CLI build, a module's `build/libs/*.jar` can end up
|
|
containing only a manifest, and Gradle then considers `jar` up-to-date. Dependent modules fail
|
|
with "Unresolved reference" on symbols that plainly exist. Fix: `rm -f <module>/build/libs/*.jar`
|
|
and re-run the `jar` task. Suspect this whenever a reference resolves in one module but not in
|
|
its consumer.
|
|
- As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
|
|
AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
|
|
Android Studio, its `jbr` directory works as `JAVA_HOME`.
|
|
|
|
## Likely next steps
|
|
|
|
1. Run on physical devices; collect JSON reports across Android versions/vendors.
|
|
2. ~~C-over-JNI errqueue shim~~ — not needed; `trace.errqueue_reachable` and `traceroute.udp4`
|
|
are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
|
|
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
|
|
`core-probe` / `core-shizuku` modules.
|