Compare commits
@@ -40,3 +40,6 @@ keystore.properties
|
|||||||
|
|
||||||
# wrangler build/dev artifacts
|
# wrangler build/dev artifacts
|
||||||
web/.wrangler/
|
web/.wrangler/
|
||||||
|
|
||||||
|
# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own
|
||||||
|
echolot-app/*/bin/
|
||||||
|
|||||||
@@ -32,10 +32,25 @@ Keep prober result IDs aligned with the measurement-schema test-type registry.
|
|||||||
|
|
||||||
## Versioning
|
## Versioning
|
||||||
|
|
||||||
Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps
|
Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental
|
||||||
for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the
|
work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are
|
||||||
Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release +
|
namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI →
|
||||||
registry image; the server on fmr can `--self-update` from those releases.
|
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
|
## Layout
|
||||||
|
|
||||||
@@ -52,6 +67,29 @@ echolot-prober/ the capability prober (self-contained Gradle buil
|
|||||||
ui/ProberScreen.kt result cards colored by verdict
|
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
|
## Conventions
|
||||||
|
|
||||||
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway).
|
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway).
|
||||||
@@ -83,6 +121,36 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
|
|||||||
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
|
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
|
||||||
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
||||||
it. Full history in build-status.md.
|
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 —
|
- 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
|
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`.
|
Android Studio, its `jbr` directory works as `JAVA_HOME`.
|
||||||
|
|||||||
@@ -271,3 +271,392 @@ against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, chec
|
|||||||
encoding — a focused batch, not a corner to rush.
|
encoding — a focused batch, not a corner to rush.
|
||||||
Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain;
|
Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain;
|
||||||
real admin UI.
|
real admin UI.
|
||||||
|
|
||||||
|
## Server self-test + host tuning — v0.3.4/v0.3.5, fmr proven good (2026-07-31)
|
||||||
|
The daemon now proves its own host is a clean measurement target:
|
||||||
|
- **sysctl audit** (`GET /admin/selftest`, startup warnings): on first run it flagged exactly 4
|
||||||
|
real issues on fmr — accept_ra=1 on a static-v6 host, accept_redirects=1, send_redirects=1,
|
||||||
|
icmp_ratelimit=1000. Recommended `server/deploy/99-echolot-sysctl.conf` applied (v6 default
|
||||||
|
route/addrs are proto static with 0 RA-derived routes, so disabling accept_ra is safe —
|
||||||
|
verified v6 egress intact after). Now sysctl_ok=true, 0 warnings.
|
||||||
|
- **egress-MTU self-proof**: DF PMTUD via IP_MTU_DISCOVER + getsockopt IP_MTU (v0.3.4 had a bug —
|
||||||
|
read IP_MTU without connecting → ENOTCONN; v0.3.5 connects first). fmr reports 1500 on both v4
|
||||||
|
and v6 → mtu_ok=true, so client MTU tests are trustworthy.
|
||||||
|
- Both signals ride in the profile as `server_selftest{mtu_ok,sysctl_ok}` so a client can skip
|
||||||
|
MTU testing when the server can't support it honestly.
|
||||||
|
fmr profile now: `{mtu_ok: true, sysctl_ok: true}`.
|
||||||
|
|
||||||
|
## Server v0.3.6 — tls-echo / JA4: spec §4 COMPLETE (2026-07-31)
|
||||||
|
The elt-echo TLS variant runs on the TCP-echo port (8441), multiplexed by a timed 0x16 peek
|
||||||
|
(plain echo stays server-speaks-first; a TLS ClientHello routes to the TLS path). It captures
|
||||||
|
the full ClientHello, returns it raw (b64) + as a JA4 fingerprint (FoxIO), then TLS byte-echoes —
|
||||||
|
the sec.clienthello_echo evidence. Hand-rolled ClientHello parser (ciphers/exts/ALPN/
|
||||||
|
supported_versions/sig-algs, GREASE-excluded), unit-tested. **Cross-client verified on fmr**:
|
||||||
|
openssl → t13d3013eo (30 ciphers), python ssl → t13d1712eo (17) — different stacks, different
|
||||||
|
fingerprints, correct _a structure both. Capability tls-echo.
|
||||||
|
|
||||||
|
Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp-probe,
|
||||||
|
delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns.
|
||||||
|
Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT
|
||||||
|
retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI.
|
||||||
|
|
||||||
|
## Production app — echolot-app/, core-protocol proven live (2026-07-31)
|
||||||
|
Started the Android client, bottom-up from the verifiable spine. `echolot-app/` is a multi-module
|
||||||
|
Gradle build; `core-protocol` is a **pure Kotlin/JVM** module (no Android SDK) implementing the
|
||||||
|
client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via
|
||||||
|
HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin),
|
||||||
|
HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
|
||||||
|
byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key
|
||||||
|
derivation provably matches the server). **Verified END-TO-END against live fmr** via
|
||||||
|
`scripts/test-fmr.sh` (mint token over SSH → enroll on public control plane → run LiveServerTest):
|
||||||
|
profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's
|
||||||
|
observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it:
|
||||||
|
java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte
|
||||||
|
requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement
|
||||||
|
(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI.
|
||||||
|
|
||||||
|
## App: core-measurement + core-engine — full server-facing vertical proven (2026-07-31)
|
||||||
|
Two more pure-Kotlin/JVM modules, both verifiable without a device:
|
||||||
|
- **core-measurement**: the measurement-schema.md document model (two-clock, columnar trains,
|
||||||
|
test-type registry, anonymization types, finding-requires-evidence). The §7.3 deterministic
|
||||||
|
verdict derivation is implemented + unit-tested; document JSON round-trips.
|
||||||
|
- **core-engine**: the run engine composing core-protocol probes into core-measurement documents.
|
||||||
|
Injected clock/UUID source (pure, testable). Runs a server ECHO train → RTT distribution, loss,
|
||||||
|
and NAT-rebinding detection (from the server's observed source port) as train.udp_updown.
|
||||||
|
**Verified END-TO-END against fmr**: 20-packet train, 0% loss, RTT 1.7/2.5/6.9ms, single
|
||||||
|
observed port (no rebinding) → valid MeasurementDocument (2.3kB), overall GREEN.
|
||||||
|
So the whole server-facing stack — protocol client → engine → schema document → verdict — is now
|
||||||
|
proven against the live server, no device needed. Next modules (core-probe device-tier,
|
||||||
|
core-shizuku dual-path, Compose app) are Android + need on-device verification.
|
||||||
|
|
||||||
|
## App: installable APK — core-probe + Compose UI (2026-07-31)
|
||||||
|
The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP
|
||||||
|
9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the `kotlin`
|
||||||
|
extension; that was the one build gotcha). Modules added:
|
||||||
|
- **core-probe** (Android lib): Probe→core-measurement Test abstraction; NetworkInventory
|
||||||
|
(LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe
|
||||||
|
(per-network icmp.ping4/6, ported from the prober's validated per-network logic).
|
||||||
|
- **app** (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a
|
||||||
|
§7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights,
|
||||||
|
networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation
|
||||||
|
(ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive
|
||||||
|
follow-ups (app degrades gracefully without them, like the prober).
|
||||||
|
Debug APK: 9.5 MB, `echolot-app/app/build/outputs/apk/debug/app-debug.apk`. Not yet run on device
|
||||||
|
(needs the user's phone). core-shizuku (dual-path executor) deferred as additive.
|
||||||
|
|
||||||
|
## App verified on-device — both phones (2026-07-31)
|
||||||
|
The production Echolot app runs on real hardware, on BOTH devices, via the beacon-managed adb:
|
||||||
|
- OnePlus 15 (CPH2747, A16) and Lenovo TB330FU (A15): link.snapshot OK, icmp.ping4 OK
|
||||||
|
(per-network, RTT ~40ms), icmp.ping6 FAILED → finding "No IPv6 ICMP path on any active network"
|
||||||
|
→ category ipv6 yellow → **Overall YELLOW**. The verdict is driven by the real broken-LAN IPv6
|
||||||
|
(RA default route, no global prefix) we first found with the prober — the product now surfaces
|
||||||
|
it end to end (probe → schema → verdict → traffic-light UI).
|
||||||
|
Wireless-adb beacon (tools/adb-beacon) made this practical: both devices self-report their
|
||||||
|
rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live
|
||||||
|
against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast
|
||||||
|
port rotation) — all handled.
|
||||||
|
|
||||||
|
## App: core-shizuku (shell tier) built + wired (2026-07-31)
|
||||||
|
Ported the prober's validated Shizuku executor into the app as a library module:
|
||||||
|
- AIDL IUserService + UserService (runs `sh -c` as shell/root in the Shizuku-spawned process),
|
||||||
|
Shizuku provider merged into the app manifest.
|
||||||
|
- **ShizukuRunner: the build-4 dual-path executor** — bind the UserService (25s + retry) where it
|
||||||
|
works (OnePlus 7/7), fall back to the legacy `Shizuku.newProcess` reflection API where it never
|
||||||
|
binds (Lenovo). `exec_path` records which path ran.
|
||||||
|
- ShizukuProbe: runs the shell battery (ip neigh / ip -6 route / ip addr / ip monitor /
|
||||||
|
network_stack DHCP / wifi dump) and emits a shizuku-tier `link.ip_monitor` Test with the raw
|
||||||
|
per-device dumps as evidence + commands_ok/exec_path metrics. Self-degrades to UNSUPPORTED when
|
||||||
|
Shizuku isn't running.
|
||||||
|
Wired into RunViewModel (runs after app-tier probes; sets tiers.shizuku). App APK assembles clean.
|
||||||
|
On-device test deferred: at build time no device was reachable (tablet wifi/beacon dropped on the
|
||||||
|
churning LAN; phone wireless debugging disabled to stop reconnect notifications). Will verify on a
|
||||||
|
device later — expecting UserService on the OnePlus, newProcess fallback on the Lenovo, per the
|
||||||
|
prober.
|
||||||
|
|
||||||
|
## App on-device: net.captive_portal verified, Shizuku degrades correctly (2026-07-31)
|
||||||
|
Installed the app (core-shizuku + net.captive_portal) on the OnePlus 15 and ran it; report archived
|
||||||
|
at `echolot-app/reports/CPH2747-app-run1.json`. Results:
|
||||||
|
- **net.captive_portal works** — Android's NetworkMonitor logic reproduced: default + wifi both
|
||||||
|
returned HTTP **204** on the HTTPS *and* HTTP generate_204 probes → `validated`; **cellular
|
||||||
|
returned neither (-1/-1) → `no_internet`**. The per-network split immediately surfaces an
|
||||||
|
asymmetry the OS hides (wifi validated, cellular can't reach the checks at all).
|
||||||
|
- **Shizuku tier degrades correctly**: `binder_alive:false` → test UNSUPPORTED, `tiers.shizuku:false`
|
||||||
|
(Shizuku isn't running on the phone). The dual-path *executing* path still needs an on-device
|
||||||
|
test with Shizuku started (expect UserService on this OnePlus).
|
||||||
|
- 5 tests now: link.snapshot ok, icmp.ping4 ok, icmp.ping6 failed, net.captive_portal ok,
|
||||||
|
link.ip_monitor(shizuku) unsupported → overall YELLOW via the IPv6 finding.
|
||||||
|
Also fixed this session: the beacon app itself caused the "wireless debugging connected"
|
||||||
|
notification spam (it re-resolved adbd's own mDNS advertisement, making adbd re-arm each time);
|
||||||
|
now resolves once per service instance and the heartbeat re-POSTs the cached port only.
|
||||||
|
|
||||||
|
## App: dns.canary verified against the live server (2026-08-01)
|
||||||
|
Built the client half of the canary-DNS measurement and verified it on the OnePlus against the
|
||||||
|
deployed fmr zone (`echolot-app/reports/CPH2747-app-run2-dns.json`):
|
||||||
|
- All four spec-frozen reference records matched byte-for-byte through the network's own resolver
|
||||||
|
(ttl-5→192.0.2.5, ttl-60→192.0.2.60, ttl-3600→192.0.2.36, ttl-86400→192.0.2.86) → nothing on
|
||||||
|
this path rewrites DNS answers (`dns.answer_integrity` in the green case).
|
||||||
|
- The un-cacheable nonce name `1006ad16.adhoc.c.echo-lot.app` resolved to 192.0.2.21 →
|
||||||
|
`reached_authoritative: true`, proving the query actually reached the canary server rather than
|
||||||
|
being answered from a cache or an interceptor.
|
||||||
|
Findings wired: `dns.answer_rewritten` (high) when a reference mismatches, and
|
||||||
|
`dns.authoritative_unreachable` (medium) when the nonce isn't answered by the canary server.
|
||||||
|
This closes the first full client↔server measurement loop: the Kotlin app measures against the Go
|
||||||
|
server's canary zone on real hardware. 6 tests now run per measurement.
|
||||||
|
|
||||||
|
## App: Shizuku shell tier VERIFIED on-device — the app is feature-complete for v1 tiers (2026-08-01)
|
||||||
|
Ran the app on the OnePlus with Shizuku running (`echolot-app/reports/CPH2747-app-run3-shizuku.json`):
|
||||||
|
- **`tiers: {app:true, shizuku:true}`**, shizuku test **ok**, **commands_ok 7/7**,
|
||||||
|
**`exec_path: UserService`** (the OnePlus binds it — matches the prober; the newProcess fallback
|
||||||
|
stays for the Lenovo).
|
||||||
|
- Evidence is the real privileged material the production parsers need: live ARP/NDP neighbor
|
||||||
|
table, per-table IPv6 routes, `ip addr`, an actual `[NEIGH]` netlink event from `ip monitor`,
|
||||||
|
IpClient DHCP logs incl. APF capabilities, and the wifi state dump — all as shell(2000).
|
||||||
|
Both privilege tiers now work end to end in the production app on real hardware, alongside the
|
||||||
|
canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6,
|
||||||
|
net.captive_portal, dns.canary, link.ip_monitor(shizuku).
|
||||||
|
|
||||||
|
## App: nat.stun_5780 — NAT behavior discovery verified against the live server (2026-08-01)
|
||||||
|
Client-side RFC 5389/5780 STUN (hand-rolled, stdlib only) exercising the server's `stun-5780`
|
||||||
|
capability. Three binding requests from ONE socket: primary, the server's OTHER-ADDRESS
|
||||||
|
(alternate IP), and CHANGE-REQUEST(port). Verified on the OnePlus
|
||||||
|
(`echolot-app/reports/CPH2747-app-run4-stun.json`):
|
||||||
|
- local `10.13.102.124` → mapped `178.191.120.247:53259`, `behind_nat: true`
|
||||||
|
- `other_address 89.185.109.151:3479` — the server's second IP answered, so RFC 5780 works
|
||||||
|
end to end (client ↔ our own STUN implementation)
|
||||||
|
- **mapping: endpoint-independent** (same external port toward a different destination → P2P
|
||||||
|
friendly); **filtering: address/port-dependent** (no reply to CHANGE-REQUEST → unsolicited
|
||||||
|
inbound is dropped). Classic full-cone-mapping + port-restricted-filtering NAT.
|
||||||
|
Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent.
|
||||||
|
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
|
||||||
|
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
|
||||||
|
throwaway connected socket). 7 tests/run.
|
||||||
|
|
||||||
|
## App: autorun mode + IPv6 severity rework (2026-08-01)
|
||||||
|
**IPv6 is no longer treated as a defect just for being absent.** The finding now depends on
|
||||||
|
whether the network actually provisioned IPv6 (a global v6 address or a `::/0` route):
|
||||||
|
- not provisioned → `ipv6.not_offered`, severity **INFO → green**. Most networks are still
|
||||||
|
IPv4-only and that is not a fault.
|
||||||
|
- provisioned but ICMPv6 fails → `ipv6.broken`, severity **MEDIUM → yellow**. Half-configured
|
||||||
|
IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a
|
||||||
|
v6 default route with no working path, so it correctly reports `ipv6.broken`.
|
||||||
|
|
||||||
|
**Autorun mode** — one adb command runs a full measurement unattended and collects the result
|
||||||
|
without any UI tapping or adb round-trip:
|
||||||
|
```
|
||||||
|
adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
curl http://<fmr>/reports # list
|
||||||
|
curl http://<fmr>/report/<name> # fetch
|
||||||
|
```
|
||||||
|
The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py
|
||||||
|
gained `POST /report`, `GET /reports`, `GET /report/<name>`), shows the result for 3 s, then
|
||||||
|
finishes itself — leaving the device as it was found. On upload failure it stays open so the
|
||||||
|
error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app
|
||||||
|
android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog.
|
||||||
|
|
||||||
|
## App: router identification, brand icons, DEV build variant (2026-08-01)
|
||||||
|
**`link.ra_source` — who is advertising IPv6 here, and what box is it?** New app-tier probe
|
||||||
|
(registry addition). Identification chain, each step recorded as evidence so nothing is guessed:
|
||||||
|
1. RA source = next-hop of the `::/0` route per network (a `fe80::` link-local).
|
||||||
|
2. **MAC recovered from the modified-EUI-64 link-local** (strip `ff:fe`, flip the U/L bit) —
|
||||||
|
e.g. `fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`. RFC 7217/privacy addresses don't encode
|
||||||
|
a MAC and are reported as such rather than guessed.
|
||||||
|
3. Vendor via a curated OUI table (`Oui.kt` — SOHO/router vendors; unknown OUIs are printed
|
||||||
|
verbatim). Locally-administered (randomized) MACs are flagged.
|
||||||
|
4. **UPnP/SSDP M-SEARCH** → the gateway's `SERVER:` banner + device-description XML gives
|
||||||
|
manufacturer / model / friendly name. This is what usually names the exact box.
|
||||||
|
5. Reverse DNS for both gateways.
|
||||||
|
All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the
|
||||||
|
gateway can still be matched — and the MAC travels with every identity source, which is the hook
|
||||||
|
for the future LLDP / mDNS cross-matching.
|
||||||
|
UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified
|
||||||
|
vendor/model.
|
||||||
|
|
||||||
|
**Icons + DEV variant.** The branding adaptive icon is now the app icon: `icon-adaptive-*.svg`
|
||||||
|
converted to Android vector drawables (SVG transform baked in, gradient background, monochrome
|
||||||
|
layer for themed icons) plus PNG mipmaps for legacy launchers. The **debug build is a separate
|
||||||
|
app**: `applicationIdSuffix .dev`, label "Echolot DEV", and a DEV-badged icon (layer-list =
|
||||||
|
production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install
|
||||||
|
and both can be installed side by side.
|
||||||
|
NOTE for tooling: the dev package is `app.echo_lot.app.dev`, activity `app.echo_lot.app.MainActivity`.
|
||||||
|
|
||||||
|
### link.ra_source verified on-device — it named the actual router (2026-08-01)
|
||||||
|
Run archived at `echolot-app/reports/CPH2747-app-run5-router-id.json`. On the wifi network the
|
||||||
|
probe identified the RA sender completely, from an unprivileged app:
|
||||||
|
- RA source `fe80::7a9a:18ff:fe54:b8f9` → **MAC 78:9A:18:54:B8:F9 recovered via EUI-64**
|
||||||
|
(matches the Shizuku neighbor table exactly) → vendor **MikroTik** by OUI
|
||||||
|
- IPv4 gateway `10.13.102.1`, reverse DNS `router.hudelist.local`
|
||||||
|
- UPnP: `RouterOS/7.23.2 UPnP/1.0 MikroTik` → manufacturer MikroTik, model Router OS,
|
||||||
|
friendly name "MikroTik Router"
|
||||||
|
So the box advertising this LAN's broken IPv6 RA is a **MikroTik running RouterOS 7.23.2**, named
|
||||||
|
by two independent methods (OUI from the address itself + UPnP device description) that corroborate.
|
||||||
|
On cellular the carrier's RA source is an RFC 7217 privacy address and is correctly reported as
|
||||||
|
"not EUI-64" rather than guessed.
|
||||||
|
The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 gateway) — the
|
||||||
|
raw material for the planned LLDP/mDNS cross-matching by MAC.
|
||||||
|
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
|
||||||
|
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
|
||||||
|
|
||||||
|
### App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
|
||||||
|
- **Progress + ETA**: `Probe.estimatedMs` (per-probe, from measured on-device durations — the
|
||||||
|
timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s,
|
||||||
|
STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted
|
||||||
|
in the total so the bar covers the whole run.
|
||||||
|
- **Cancel**: stops an in-flight run and shows what was measured so far, assembled into a normal
|
||||||
|
document (findings + verdict over the partial set). Deliberately **does not upload** — a partial
|
||||||
|
run is for the person looking at the screen, not for the record.
|
||||||
|
- **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the
|
||||||
|
status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which
|
||||||
|
covers status bar, navigation bar and display cutout.
|
||||||
|
|
||||||
|
### Shell-tier readiness shown before a run (2026-08-01)
|
||||||
|
`ShizukuAvailability` distinguishes four states and the UI only speaks when it is actionable:
|
||||||
|
- **NOT_INSTALLED → says nothing.** Users who don't use Shizuku are never nagged.
|
||||||
|
- **INSTALLED_NOT_RUNNING → amber banner** "Shizuku is installed but not running — start it to
|
||||||
|
include shell-tier tests". This is the case worth reminding about: the user has it, but a
|
||||||
|
stopped service silently costs them the whole shell tier.
|
||||||
|
- NEEDS_PERMISSION → "running but not authorised, it will ask on first use".
|
||||||
|
- READY → green "shell-tier tests will run".
|
||||||
|
Detection is listener-based (`addBinderReceivedListenerSticky` + binder-dead), because
|
||||||
|
`pingBinder()` is only truthful once ShizukuProvider has delivered the binder — a one-shot poll at
|
||||||
|
launch would show a false "not running". Installed-vs-not needs the `<queries>` package-visibility
|
||||||
|
entry on Android 11+. Verified on-device: with shizuku_server stopped the banner appears correctly.
|
||||||
|
|
||||||
|
### Shizuku banner is actionable; progress + cancel verified on-device (2026-08-01)
|
||||||
|
Tapping the shell-tier banner now does the right thing per state: **installed-but-stopped** →
|
||||||
|
deep-links into the Shizuku app (a third-party app *cannot* start Shizuku itself; the wireless-
|
||||||
|
debugging pairing flow is privileged and lives in that app, so taking the user there in one tap is
|
||||||
|
the best available), **running-but-unauthorised** → fires the Shizuku permission request directly.
|
||||||
|
The hint line states which action the tap performs.
|
||||||
|
Verified on-device in one screenshot: progress bar at "test 4 of 8 · icmp.ping6 · ~33s left",
|
||||||
|
Cancel button beside the disabled Run button, title clear of the status bar/cutout, and the banner
|
||||||
|
having live-switched from "installed but not running" to "running but not authorised" via the
|
||||||
|
binder listener when Shizuku was started mid-session.
|
||||||
|
|
||||||
|
### Why the Shizuku banner can't start wireless debugging directly (verified, 2026-08-01)
|
||||||
|
Checked against Shizuku 13.6's own manifest (pulled the APK, `aapt2 dump xmltree`): the
|
||||||
|
wireless-debugging entry points — `moe.shizuku.manager.adb.AdbPairingTutorialActivity`,
|
||||||
|
`moe.shizuku.manager.adb.AdbPairingService`, `moe.shizuku.manager.starter.StarterActivity` —
|
||||||
|
declare **no intent filters**, so they are not exported and a third-party app cannot launch them.
|
||||||
|
`MainActivity` is the only reachable entry and answers MAIN/LAUNCHER only (no deep link), which is
|
||||||
|
why the handoff lands on the screen whose primary action is the root start.
|
||||||
|
Best available behavior, now implemented: the banner still opens Shizuku, but the hint names the
|
||||||
|
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
|
||||||
|
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
|
||||||
|
debugging must be enabled first for Shizuku's wireless start to work at all.
|
||||||
|
|
||||||
|
### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
|
||||||
|
The client can measure a round trip and the largest packet it can *send*. It cannot measure the
|
||||||
|
largest packet it can *receive*, or downstream-only loss — those need the server to push, which is
|
||||||
|
exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
|
||||||
|
|
||||||
|
- **`session.Grant`** — created per action, bound at creation to the session's *observed*
|
||||||
|
data-plane source (no grant without a verified destination), clamped to server limits, with a
|
||||||
|
byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.
|
||||||
|
- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss,
|
||||||
|
reordering and inter-arrival spacing.
|
||||||
|
- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size
|
||||||
|
that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only
|
||||||
|
says whether fragments get through — a different fact, and the reason the schema has both
|
||||||
|
`mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the
|
||||||
|
startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by
|
||||||
|
our kernel is never read as a limit of the client's path.
|
||||||
|
|
||||||
|
Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery
|
||||||
|
up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a
|
||||||
|
3000 µs send interval.
|
||||||
|
|
||||||
|
#### Two bugs this shook out, both invisible in a single-homed lab
|
||||||
|
1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two
|
||||||
|
IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind
|
||||||
|
list. A train for a session established on `.150` left from `.151` and every packet was dropped
|
||||||
|
by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the
|
||||||
|
client saw none — reported as *100 % downstream loss*, a confident measurement of something
|
||||||
|
that never happened. Sessions now record which of our own bound addresses received their
|
||||||
|
traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the
|
||||||
|
family fallback.
|
||||||
|
2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at
|
||||||
|
zero client-side while the server's anti-replay window keeps counting, so every packet is
|
||||||
|
discarded as a replay — and because the server then never records the new source, the grant
|
||||||
|
still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole
|
||||||
|
run; `ProbeSession`'s doc comment states the constraint.
|
||||||
|
|
||||||
|
### Run archive, anonymizer and uploads (2026-08-01)
|
||||||
|
Three pieces, deliberately separate:
|
||||||
|
|
||||||
|
- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can
|
||||||
|
inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is
|
||||||
|
enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three
|
||||||
|
ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto
|
||||||
|
all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.
|
||||||
|
- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes
|
||||||
|
nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a
|
||||||
|
public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour
|
||||||
|
inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics,
|
||||||
|
statuses and finding codes. Pseudonyms are consistent within a document and — by default — not
|
||||||
|
across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for
|
||||||
|
people diffing their own history. Classification is one readable table, not annotations spread
|
||||||
|
across modules. 14 tests, each pinning a property someone's privacy depends on.
|
||||||
|
- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max
|
||||||
|
runs per device, and the *least* anonymization accepted. The profile advertises all of it so the
|
||||||
|
app presents the choice honestly instead of discovering the rules by being rejected. `account`
|
||||||
|
refuses today rather than falling back to anonymous: picking the strict setting before OIDC
|
||||||
|
lands must not silently mean the loose one.
|
||||||
|
|
||||||
|
**The archive holds the unredacted document; redaction happens on the way out, per upload.** The
|
||||||
|
local archive is the user's own data on their own device, and redacting it would destroy exactly
|
||||||
|
the detail that makes a week-old run worth keeping.
|
||||||
|
|
||||||
|
App-side: settings screen (archive limits, privacy level with a plain-language description of what
|
||||||
|
each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether
|
||||||
|
each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer
|
||||||
|
the user cannot inspect is only a promise.
|
||||||
|
|
||||||
|
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the
|
||||||
|
SSDP neighbour name and the free-text note are absent from what the server stores while the
|
||||||
|
finding code and the metrics survive, then deleted it.
|
||||||
|
|
||||||
|
### Still open
|
||||||
|
- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval.
|
||||||
|
- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
|
||||||
|
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
|
||||||
|
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
|
||||||
|
against the live server. On-device verification is the next step.
|
||||||
|
|
||||||
|
### SemVer compatibility windows between app and server (server-v0.5.0 … v0.5.2, 2026-08-01)
|
||||||
|
Both artifacts are SemVer, and each now declares — and enforces — which peer versions it will talk
|
||||||
|
to. Spec: `docs/probe-protocol.md` §8.
|
||||||
|
|
||||||
|
**Two axes, deliberately not conflated.** Release versions are a *proxy* for what actually has to
|
||||||
|
match, so the real thing is checked first:
|
||||||
|
- `protocol_version` — **can** these builds talk. Advertised in the profile; a peer in a different
|
||||||
|
breaking series is refused whatever its release version says. Below 1.0.0 the **minor** is the
|
||||||
|
breaking axis (SemVer §4).
|
||||||
|
- release-version window — **may** they, per policy. `[min, max)`, min inclusive, max exclusive,
|
||||||
|
because the useful bound is always "the version that broke it".
|
||||||
|
|
||||||
|
Bounds sit at breaking boundaries, not at releases, so shipping a patch never requires editing a
|
||||||
|
range. The app requires server `>= 0.4.2` for a stated reason, not caution: earlier multi-homed
|
||||||
|
servers mis-addressed granted sends and the client measured 100 % downstream loss that never
|
||||||
|
happened. Operators override the server side with `ECHOLOT_MIN_APP_VERSION` /
|
||||||
|
`ECHOLOT_MAX_APP_VERSION`; a malformed bound is fatal at startup rather than ignored, so a typo
|
||||||
|
cannot silently disable a restriction.
|
||||||
|
|
||||||
|
Three rules that shaped the implementation:
|
||||||
|
1. **`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.
|
||||||
|
2. **An unparseable or absent version is `unknown`, and is allowed.** Dev builds report `dev`, and a
|
||||||
|
client too old to send the header cannot be identified anyway.
|
||||||
|
3. **Refusal is 426 with a body naming both versions and the window**, surfaced client-side as a
|
||||||
|
distinct `VersionRefused` rather than folded into "network error".
|
||||||
|
|
||||||
|
The app's `versionCode` is now derived from its SemVer (`major*1e6 + minor*1e4 + patch*10`) instead
|
||||||
|
of being a second number to remember.
|
||||||
|
|
||||||
|
Verified live against fmr (`LiveCompatTest`): profile advertises the window and stays readable for a
|
||||||
|
refused version; 0.1.0 and 99.0.0 are both refused with actionable messages; 0.2.0 and a missing
|
||||||
|
header are both served.
|
||||||
|
|
||||||
|
One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`, `>` and `&` by
|
||||||
|
default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this
|
||||||
|
is an API, not a page), and the client now *parses* the error field instead of pattern-matching it,
|
||||||
|
so it survives whatever a future encoder decides to escape.
|
||||||
|
|||||||
@@ -192,14 +192,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference.
|
|||||||
|
|
||||||
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.
|
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.
|
||||||
|
|
||||||
## 8. Cross-references to the measurement schema
|
## 8. Version compatibility
|
||||||
|
|
||||||
|
Both artifacts are versioned with **SemVer**: the Go server (`server-vX.Y.Z` tags) and the Android
|
||||||
|
app (`versionName`; `versionCode` is derived from it, never maintained separately). Two independent
|
||||||
|
things are checked, and conflating them is the mistake this section exists to prevent.
|
||||||
|
|
||||||
|
### 8.1 Protocol version — *can* these builds talk?
|
||||||
|
|
||||||
|
`protocol_version` is the version of **this document**. It is advertised in the profile
|
||||||
|
(`compat.protocol_version`) and is the correctness axis: a peer in a different breaking series
|
||||||
|
cannot be talked to, whatever its release version says. Below `1.0.0` the **minor** is the breaking
|
||||||
|
axis (SemVer §4); at and above it, the major is. A patch bump of the protocol never splits a fleet.
|
||||||
|
|
||||||
|
### 8.2 Release-version window — *should* they, per policy?
|
||||||
|
|
||||||
|
Each side declares the range of peer release versions it will work with, as `[min, max)` —
|
||||||
|
**minimum inclusive, maximum exclusive**, because the useful bound is always "the version that
|
||||||
|
broke it" and writing that literally is unambiguous. An empty maximum means unbounded.
|
||||||
|
|
||||||
|
The server advertises its window and enforces it:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"compat": {
|
||||||
|
"protocol_version": "1.0.0",
|
||||||
|
"schema_version": "1.0.0",
|
||||||
|
"app_min": "0.2.0",
|
||||||
|
"app_max": "1.0.0" // exclusive; "" = no upper bound
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operators override it with `ECHOLOT_MIN_APP_VERSION` / `ECHOLOT_MAX_APP_VERSION` (or
|
||||||
|
`--min-app-version` / `--max-app-version`). A malformed bound is **fatal at startup**, not ignored:
|
||||||
|
a typo must not silently disable a restriction the operator meant to set.
|
||||||
|
|
||||||
|
The app sends its version on every control-plane request:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Echolot-App-Version: 0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
and carries its own bounds for the server (`MIN_SERVER` / `MAX_SERVER` in `Compat.kt`). It checks
|
||||||
|
the profile in **both** directions — is the server in our range, and are we in the server's — so a
|
||||||
|
mismatch is reported before a run starts rather than discovered halfway through one.
|
||||||
|
|
||||||
|
### 8.3 Rules
|
||||||
|
|
||||||
|
1. **`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, defeating the check.
|
||||||
|
2. **Refusal is `426 Upgrade Required`**, with a body naming both versions and the accepted window:
|
||||||
|
```json
|
||||||
|
{ "error": "app 0.1.0 is older than this build supports (needs >= 0.2.0, < 1.0.0). Update the app.",
|
||||||
|
"app_version": "0.1.0", "accepts_app": ">= 0.2.0, < 1.0.0",
|
||||||
|
"server_version": "0.5.0", "protocol_version": "1.0.0" }
|
||||||
|
```
|
||||||
|
3. **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. The check exists to
|
||||||
|
turn confusing failures into clear ones; refusing what it cannot identify does the opposite.
|
||||||
|
4. **Bounds move at breaking boundaries, not at releases.** Shipping a patch must never require
|
||||||
|
editing a range. A minimum is raised only when older peers are actually harmful — e.g. the app
|
||||||
|
requires server `>= 0.4.2` because earlier multi-homed servers sent granted traffic from an
|
||||||
|
address the session never used, which the client measured as 100 % downstream loss. A
|
||||||
|
confidently wrong measurement is worse than a refused one.
|
||||||
|
|
||||||
|
## 9. Cross-references to the measurement schema
|
||||||
|
|
||||||
- Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2).
|
- Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2).
|
||||||
- `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note).
|
- `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note).
|
||||||
- Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`.
|
- Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`.
|
||||||
- `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`.
|
- `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`.
|
||||||
|
|
||||||
## 9. Open items
|
## 10. Open items
|
||||||
|
|
||||||
1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit.
|
1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit.
|
||||||
2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design.
|
2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
kotlin version: 2.2.10
|
||||||
|
error message: Daemon compilation failed: null
|
||||||
|
java.lang.Exception
|
||||||
|
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:69)
|
||||||
|
at org.jetbrains.kotlin.daemon.common.CompileService$CallResult$Error.get(CompileService.kt:65)
|
||||||
|
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:240)
|
||||||
|
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
|
||||||
|
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
|
||||||
|
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
|
||||||
|
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:68)
|
||||||
|
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:64)
|
||||||
|
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:61)
|
||||||
|
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:102)
|
||||||
|
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:61)
|
||||||
|
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
|
||||||
|
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
|
||||||
|
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
|
||||||
|
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
|
||||||
|
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:58)
|
||||||
|
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
|
||||||
|
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||||
|
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:191)
|
||||||
|
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$500(DefaultConditionalExecutionQueue.java:112)
|
||||||
|
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:168)
|
||||||
|
at org.gradle.internal.Factories$1.create(Factories.java:30)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.lambda$runAndReleaseLocks$0(DefaultWorkerLeaseService.java:300)
|
||||||
|
at org.gradle.internal.work.ResourceLockStatistics$1.measure(ResourceLockStatistics.java:43)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.runAndReleaseLocks(DefaultWorkerLeaseService.java:298)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocksAcquired(DefaultWorkerLeaseService.java:294)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:286)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:130)
|
||||||
|
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:135)
|
||||||
|
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:163)
|
||||||
|
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:125)
|
||||||
|
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
|
||||||
|
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
|
||||||
|
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:47)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
|
||||||
|
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
|
||||||
|
at java.base/java.lang.Thread.run(Unknown Source)
|
||||||
|
Caused by: java.nio.file.NoSuchFileException: C:\Users\mram.AD\AppData\Local\Temp\kotlin-backups9735132086774701577\24.backup -> C:\Users\mram.AD\dev\echolot\echolot-app\core-protocol\build\classes\kotlin\main\META-INF\core-protocol.kotlin_module
|
||||||
|
at java.base/sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
|
||||||
|
at java.base/sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
|
||||||
|
at java.base/sun.nio.fs.WindowsFileCopy.move(Unknown Source)
|
||||||
|
at java.base/sun.nio.fs.WindowsFileSystemProvider.move(Unknown Source)
|
||||||
|
at java.base/java.nio.file.Files.move(Unknown Source)
|
||||||
|
at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.revertChanges(CompilationTransaction.kt:231)
|
||||||
|
at org.jetbrains.kotlin.incremental.RecoverableCompilationTransaction.close(CompilationTransaction.kt:256)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:740)
|
||||||
|
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:124)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:679)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:93)
|
||||||
|
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1806)
|
||||||
|
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
|
||||||
|
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
|
||||||
|
at java.base/java.security.AccessController.doPrivileged(Unknown Source)
|
||||||
|
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
|
||||||
|
... 3 more
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Echolot app
|
||||||
|
|
||||||
|
The production Android client ([spec](../docs/)). Native Kotlin + Jetpack Compose. Multi-module;
|
||||||
|
built bottom-up from a verifiable protocol spine.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Type | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `core-protocol` | pure Kotlin/JVM | **done** — client half of `probe-protocol.md`, verified live against the server |
|
||||||
|
| `core-measurement` | pure Kotlin/JVM | planned — `measurement-schema.md` types |
|
||||||
|
| `core-probe` | Android lib | planned — app-tier probes, ported from `echolot-prober` |
|
||||||
|
| `core-shizuku` | Android lib | planned — dual-path executor (UserService + newProcess fallback) |
|
||||||
|
| `app` | Android app | planned — Compose UI |
|
||||||
|
|
||||||
|
`core-protocol` is deliberately Android-free so it builds and unit-tests on any JDK (no Android
|
||||||
|
SDK) and can run **integration tests against a live server**.
|
||||||
|
|
||||||
|
## core-protocol
|
||||||
|
|
||||||
|
Implements the control plane (SPKI-pinned enrollment/profile/sessions via `HttpsURLConnection` —
|
||||||
|
Android-API-1 compatible, hostname verification off because trust is the pin), the HKDF-SHA256
|
||||||
|
session-key schedule, and the binary ELT1 UDP data plane (HMAC gate, ECHO + observation block,
|
||||||
|
MTU probe) — byte-compatible with the Go server.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew :core-protocol:test # unit tests (crypto vectors, wire round-trip)
|
||||||
|
scripts/test-fmr.sh # live end-to-end test against the deployed server
|
||||||
|
```
|
||||||
|
|
||||||
|
`test-fmr.sh` mints an enrollment token over SSH, enrolls via the public control plane, computes
|
||||||
|
the SPKI pin from the served cert, and runs `LiveServerTest` — proving the client speaks the wire
|
||||||
|
protocol to the real server (enroll → profile → session → echo+observation → MTU → observations).
|
||||||
|
The live test self-skips when `ECHOLOT_LIVE_*` env vars are absent, so unit runs and CI stay green
|
||||||
|
offline.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev tool (NOT the product app): reports this phone's rotating wireless-debug
|
||||||
|
// endpoint to the fmr beacon so the PC can keep `adb connect` current. Reads
|
||||||
|
// the connect port from adbd's own mDNS advertisement (_adb-tls-connect._tcp)
|
||||||
|
// via NsdManager — no root, no Shizuku.
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.adbbeacon"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.echo_lot.adbbeacon"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
// Prefilled beacon config (dev tool — secret in the APK is fine).
|
||||||
|
buildConfigField("String", "BEACON_URL", "\"http://89.185.109.150:443/beacon\"")
|
||||||
|
buildConfigField("String", "BEACON_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||||
|
}
|
||||||
|
buildTypes { release { isMinifyEnabled = false } }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures { buildConfig = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation("androidx.activity:activity:1.9.3")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="Echolot ADB Beacon"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light">
|
||||||
|
|
||||||
|
<activity android:name=".MainActivity" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BeaconService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="wireless-debug-endpoint-beacon" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.adbbeacon
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that tracks this phone's wireless-debug endpoint and reports it to the fmr
|
||||||
|
* beacon. The port comes from adbd's own mDNS advertisement (`_adb-tls-connect._tcp`) via
|
||||||
|
* NsdManager — continuous discovery, so a port rotation re-fires and re-reports within seconds.
|
||||||
|
* The IP is the wlan0 private IPv4 (what the PC routes to). No root, no Shizuku.
|
||||||
|
*/
|
||||||
|
class BeaconService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private lateinit var nsd: NsdManager
|
||||||
|
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||||
|
|
||||||
|
@Volatile private var currentPort: Int = -1
|
||||||
|
/** Service instances already resolved — prevents repeat resolves (notification spam). */
|
||||||
|
private val resolvedOnce = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
private var heartbeat: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
nsd = getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
|
startForeground(1, buildNotification("Starting…"))
|
||||||
|
startDiscovery()
|
||||||
|
// Re-assert the endpoint periodically, but do NOT re-resolve mDNS each time:
|
||||||
|
// resolving adbd's own advertisement provokes it to re-arm the connection, which fires
|
||||||
|
// Android's "wireless debugging connected" notification — every cycle. Discovery runs
|
||||||
|
// once; we only re-POST the cached port (cheap, silent).
|
||||||
|
heartbeat = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(60_000)
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status.set("watching for wireless-debug port…")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDiscovery() {
|
||||||
|
val listener = object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onStartDiscoveryFailed(t: String?, code: Int) { Status.set("NSD start failed ($code)") }
|
||||||
|
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
|
||||||
|
override fun onDiscoveryStarted(t: String?) {}
|
||||||
|
override fun onDiscoveryStopped(t: String?) {}
|
||||||
|
override fun onServiceLost(s: NsdServiceInfo?) {
|
||||||
|
// adbd stops advertising when Wireless debugging is turned off.
|
||||||
|
currentPort = -1
|
||||||
|
s?.serviceName?.let { resolvedOnce.remove(it) } // allow one re-resolve when it returns
|
||||||
|
val warn = "⚠ Wireless debugging appears OFF (adb mDNS service gone) — re-enable it"
|
||||||
|
Status.set(warn)
|
||||||
|
updateNotification(warn)
|
||||||
|
}
|
||||||
|
override fun onServiceFound(s: NsdServiceInfo?) {
|
||||||
|
if (s == null) return
|
||||||
|
// Resolve ONCE per discovered service instance. Repeatedly resolving adbd's own
|
||||||
|
// advertisement makes it re-arm the connection and spam the user with
|
||||||
|
// "wireless debugging connected" notifications.
|
||||||
|
val key = s.serviceName ?: return
|
||||||
|
if (!resolvedOnce.add(key)) return
|
||||||
|
resolve(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discoveryListener = listener
|
||||||
|
runCatching {
|
||||||
|
nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
|
||||||
|
}.onFailure { Status.set("NSD unavailable: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun resolve(info: NsdServiceInfo) {
|
||||||
|
nsd.resolveService(info, object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(s: NsdServiceInfo?, code: Int) {}
|
||||||
|
override fun onServiceResolved(s: NsdServiceInfo?) {
|
||||||
|
s ?: return
|
||||||
|
// On a shared LAN, NsdManager discovers EVERY device's adb
|
||||||
|
// advertisement — accept only the one whose host is THIS device's
|
||||||
|
// own IP, else we'd report a neighbour's port for our IP.
|
||||||
|
val host = s.host?.hostAddress
|
||||||
|
val mine = wifiIpv4()
|
||||||
|
if (host != null && mine != null && host != mine) return
|
||||||
|
currentPort = s.port
|
||||||
|
scope.launch { report() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() {
|
||||||
|
val ip = wifiIpv4() ?: run { Status.set("no wlan0 IPv4 (is wifi up?)"); return }
|
||||||
|
val port = currentPort
|
||||||
|
if (port <= 0) {
|
||||||
|
// No adb advertisement: either discovery hasn't landed yet, or (usually) Wireless
|
||||||
|
// debugging is off. Say so plainly.
|
||||||
|
val warn = "⚠ $ip — no wireless-debug port. Is Wireless debugging ON?"
|
||||||
|
Status.set(warn); updateNotification(warn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val device = android.os.Build.MODEL.replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||||
|
val body = """{"device":"$device","ip":"$ip","port":$port}"""
|
||||||
|
// Send over a VALIDATED internet network — the wireless-debug wifi is often a restricted
|
||||||
|
// LAN with no real internet TCP egress, so bind the report to cellular/whatever actually
|
||||||
|
// reaches the beacon.
|
||||||
|
val net = internetNetwork()
|
||||||
|
val ok = runCatching {
|
||||||
|
val url = URL(BuildConfig.BEACON_URL)
|
||||||
|
val conn = (net?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
|
||||||
|
conn.run {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 5000; readTimeout = 5000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
setRequestProperty("X-Beacon-Secret", BuildConfig.BEACON_SECRET)
|
||||||
|
outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
responseCode == 200
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
val via = if (net != null) "via ${netLabel(net)}" else "via default net"
|
||||||
|
val line = if (ok) {
|
||||||
|
"reported [$device] $ip:$port ✓ $via\n→ ${BuildConfig.BEACON_URL}"
|
||||||
|
} else {
|
||||||
|
"report FAILED for [$device] $ip:$port $via\n→ POST ${BuildConfig.BEACON_URL}"
|
||||||
|
}
|
||||||
|
Status.set(line)
|
||||||
|
updateNotification(if (ok) "reported $ip:$port ✓" else "report failed for $ip:$port")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A network with validated internet access, preferring cellular (the wireless-debug wifi is
|
||||||
|
* frequently a restricted LAN that can't reach the beacon over TCP). */
|
||||||
|
private fun internetNetwork(): android.net.Network? {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
var fallback: android.net.Network? = null
|
||||||
|
for (n in cm.allNetworks) {
|
||||||
|
val c = cm.getNetworkCapabilities(n) ?: continue
|
||||||
|
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue
|
||||||
|
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)) continue
|
||||||
|
if (c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR)) return n
|
||||||
|
fallback = n
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun netLabel(n: android.net.Network): String {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
val c = cm.getNetworkCapabilities(n) ?: return "net"
|
||||||
|
return when {
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||||
|
else -> "net"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wlan0 (or first private) IPv4 the PC routes to. */
|
||||||
|
private fun wifiIpv4(): String? {
|
||||||
|
return runCatching {
|
||||||
|
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||||
|
.filter { it.isUp && !it.isLoopback }
|
||||||
|
.sortedByDescending { it.name.startsWith("wlan") } // prefer wlan0
|
||||||
|
.flatMap { it.inetAddresses.asSequence() }
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.firstOrNull { it.isSiteLocalAddress }
|
||||||
|
?.hostAddress
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(text: String): Notification {
|
||||||
|
val channelId = "beacon"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(channelId, "ADB Beacon", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, channelId)
|
||||||
|
.setContentTitle("Echolot ADB Beacon")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
getSystemService(NotificationManager::class.java).notify(1, buildNotification(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
||||||
|
heartbeat?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
Status.set("stopped")
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny shared status the activity polls (keeps the app dependency-free of observers). */
|
||||||
|
object Status {
|
||||||
|
@Volatile var line: String = "idle"; private set
|
||||||
|
fun set(s: String) { line = s }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.adbbeacon
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal control surface for the beacon: Start/Stop the foreground service and show its live
|
||||||
|
* status. Deliberately plain (no Compose) — it's a dev tool.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var status: TextView
|
||||||
|
private val ui = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(48, 64, 48, 48)
|
||||||
|
}
|
||||||
|
val title = TextView(this).apply { text = "Echolot ADB Beacon"; textSize = 22f }
|
||||||
|
val subtitle = TextView(this).apply {
|
||||||
|
text = "Reports this phone's wireless-debug endpoint to fmr so the PC can keep adb connected.\n\n" +
|
||||||
|
"Enable Wireless debugging, then Start."
|
||||||
|
textSize = 13f; setPadding(0, 16, 0, 32)
|
||||||
|
}
|
||||||
|
status = TextView(this).apply { text = Status.line; textSize = 14f; gravity = Gravity.START }
|
||||||
|
|
||||||
|
val start = Button(this).apply {
|
||||||
|
text = "Start beacon"
|
||||||
|
setOnClickListener {
|
||||||
|
val i = Intent(this@MainActivity, BeaconService::class.java)
|
||||||
|
ContextCompat.startForegroundService(this@MainActivity, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stop = Button(this).apply {
|
||||||
|
text = "Stop beacon"
|
||||||
|
setOnClickListener { stopService(Intent(this@MainActivity, BeaconService::class.java)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addView(title); root.addView(subtitle)
|
||||||
|
root.addView(start); root.addView(stop)
|
||||||
|
root.addView(TextView(this).apply { text = "\nStatus:"; setPadding(0, 32, 0, 8) })
|
||||||
|
root.addView(status)
|
||||||
|
setContentView(root)
|
||||||
|
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun poll() {
|
||||||
|
status.text = Status.line
|
||||||
|
ui.postDelayed({ poll() }, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin — no kotlin.android here (see core-probe note).
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The app's version is SemVer and lives here, once. versionCode is derived from it rather than
|
||||||
|
// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number
|
||||||
|
// that a human has to remember to bump is a number that eventually disagrees with the first — and
|
||||||
|
// the version is now load-bearing, since the server decides whether to serve us by it.
|
||||||
|
//
|
||||||
|
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
||||||
|
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
||||||
|
val appVersionName = "0.2.0"
|
||||||
|
|
||||||
|
fun versionCodeOf(semver: String): Int {
|
||||||
|
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||||
|
return major * 1_000_000 + minor * 10_000 + patch * 10
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.app"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.echo_lot.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = versionCodeOf(appVersionName)
|
||||||
|
versionName = appVersionName
|
||||||
|
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
||||||
|
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||||
|
// The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's
|
||||||
|
// parser anyway — sent to servers so they can apply their compatibility window.
|
||||||
|
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release { isMinifyEnabled = false }
|
||||||
|
debug {
|
||||||
|
// The dev build is a separate app: own package (installs alongside a real
|
||||||
|
// Echolot), own label and a DEV-badged icon (src/debug/res).
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
versionNameSuffix = "-dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(project(":core-protocol"))
|
||||||
|
implementation(project(":core-engine"))
|
||||||
|
implementation(project(":core-probe"))
|
||||||
|
implementation(project(":core-shizuku"))
|
||||||
|
implementation(project(":core-privacy"))
|
||||||
|
implementation(project(":core-archive"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Debug foreground: the production mark with a DEV ribbon so the dev build is
|
||||||
|
unmistakable on the launcher next to a real install. -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<item android:drawable="@drawable/ic_dev_badge"/>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Echolot DEV</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.Echolot">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<!--
|
||||||
|
Enrollment bootstrap (probe-protocol.md §2.1): echolot://enroll?v=1&u=…&p=…&t=…
|
||||||
|
Scanning a QR or tapping a link the operator sent configures the server in one
|
||||||
|
action, instead of transcribing a URL, a base64 pin and a token by hand — the pin
|
||||||
|
in particular fails silently when it is wrong by one character.
|
||||||
|
-->
|
||||||
|
<intent-filter android:autoVerify="false">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="echolot" android:host="enroll" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.echo_lot.archive.ArchivedRun
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archived runs, newest first.
|
||||||
|
*
|
||||||
|
* Each row states plainly whether the run left the device, because "is this backed up / did I
|
||||||
|
* share this?" is the question a history list actually gets asked.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
runs: List<ArchivedRun>,
|
||||||
|
status: String?,
|
||||||
|
onOpen: (String) -> Unit,
|
||||||
|
onUpload: (String) -> Unit,
|
||||||
|
onDelete: (String) -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextButton(onClick = onBack) { Text("‹ Back") }
|
||||||
|
Text("History", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||||
|
|
||||||
|
if (runs.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"No archived runs yet. Finished runs are kept here automatically unless you turn " +
|
||||||
|
"archiving off in settings.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
items(runs, key = { it.id }) { r ->
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
r.verdict?.uppercase() ?: "—",
|
||||||
|
color = verdictTint(r.verdict),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
" " + humanTime(r.savedAtEpochMs),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}"
|
||||||
|
else "on this device only",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
TextButton(onClick = { onOpen(r.id) }) { Text("Export") }
|
||||||
|
TextButton(onClick = { onUpload(r.id) }) {
|
||||||
|
Text(if (r.uploaded) "Upload again" else "Upload")
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onDelete(r.id) }) { Text("Delete") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verdictTint(v: String?): Color = when (v?.lowercase()) {
|
||||||
|
"green", "ok", "pass" -> Color(0xFF7FD17F)
|
||||||
|
"yellow", "warn" -> Color(0xFFE0C060)
|
||||||
|
"red", "fail" -> Color(0xFFE07070)
|
||||||
|
else -> Color(0xFFBBBBBB)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val stamp: DateTimeFormatter =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
|
||||||
|
|
||||||
|
private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs))
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
|
||||||
|
/** The app's three top-level screens. */
|
||||||
|
private enum class Screen { RUN, HISTORY, SETTINGS }
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
requestRuntimePermissions()
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
val vm: RunViewModel = viewModel()
|
||||||
|
// Three flat screens, so a plain state variable beats a navigation library:
|
||||||
|
// there is no back stack to model beyond "return to the run screen".
|
||||||
|
var screen by remember { mutableStateOf(Screen.RUN) }
|
||||||
|
var preview by remember { mutableStateOf<String?>(null) }
|
||||||
|
// Automation entry point:
|
||||||
|
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
// starts a run immediately and uploads the report, so an unattended
|
||||||
|
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||||
|
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
||||||
|
|
||||||
|
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
|
||||||
|
// app straight into settings with the enrollment already done, so the user
|
||||||
|
// sees the result rather than a form they still have to fill in.
|
||||||
|
val enrollUri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
||||||
|
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
||||||
|
if (enrollUri != null) {
|
||||||
|
vm.enroll(enrollUri)
|
||||||
|
screen = Screen.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
|
if (autorun) vm.run(devUpload = true)
|
||||||
|
}
|
||||||
|
// In autorun the app is a batch job: once the run is done AND the upload
|
||||||
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
|
// was found. On failure it stays open so the error is visible.
|
||||||
|
val st = vm.state
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
|
||||||
|
if (autorun && !st.running && st.document != null &&
|
||||||
|
st.uploadStatus?.startsWith("uploaded") == true
|
||||||
|
) {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (screen) {
|
||||||
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
|
settings = vm.settings,
|
||||||
|
archivedRuns = vm.state.history.size,
|
||||||
|
archivedBytes = vm.archivedBytes(),
|
||||||
|
onApplyRetention = vm::applyRetention,
|
||||||
|
onDeleteAll = vm::deleteAllRuns,
|
||||||
|
onPreviewUpload = {
|
||||||
|
// Preview the newest run, since that is the one the user just made
|
||||||
|
// and the one they are deciding about.
|
||||||
|
vm.state.history.firstOrNull()?.let { r ->
|
||||||
|
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCheckServer = vm::checkServer,
|
||||||
|
onEnroll = vm::enroll,
|
||||||
|
serverStatus = vm.state.archiveStatus,
|
||||||
|
onBack = { screen = Screen.RUN },
|
||||||
|
)
|
||||||
|
Screen.HISTORY -> HistoryScreen(
|
||||||
|
runs = vm.state.history,
|
||||||
|
status = vm.state.archiveStatus,
|
||||||
|
onOpen = { id ->
|
||||||
|
lifecycleScope.launch {
|
||||||
|
vm.readRun(id)?.let { text ->
|
||||||
|
startActivity(
|
||||||
|
Intent.createChooser(
|
||||||
|
Report.shareJson(this@MainActivity, id, text),
|
||||||
|
"Export Echolot run",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpload = vm::uploadRun,
|
||||||
|
onDelete = vm::deleteRun,
|
||||||
|
onBack = { screen = Screen.RUN },
|
||||||
|
)
|
||||||
|
Screen.RUN -> EcholotScreen(
|
||||||
|
state = vm.state,
|
||||||
|
onRun = { vm.run() },
|
||||||
|
onCancel = vm::cancel,
|
||||||
|
onDeveloperOptions = {
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.developerOptionsIntent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShizukuAction = {
|
||||||
|
// Shizuku can only be started from its own app (the pairing flow lives
|
||||||
|
// there), so send the user straight to it; if it is already running we
|
||||||
|
// just need permission.
|
||||||
|
when (vm.state.shizukuState) {
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.NEEDS_PERMISSION ->
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.requestPermission()
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING ->
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.launchIntent(this)
|
||||||
|
?.let { startActivity(it) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||||
|
onOpenSettings = { screen = Screen.SETTINGS },
|
||||||
|
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
preview?.let { text ->
|
||||||
|
UploadPreviewDialog(text) { preview = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestRuntimePermissions() {
|
||||||
|
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
val missing = perms.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||||
|
Verdict.GREEN -> Color(0xFF2E7D32)
|
||||||
|
Verdict.YELLOW -> Color(0xFFF9A825)
|
||||||
|
Verdict.RED -> Color(0xFFC62828)
|
||||||
|
Verdict.INCONCLUSIVE -> Color(0xFF616161)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColor(s: TestStatus): Color = when (s) {
|
||||||
|
TestStatus.OK -> Color(0xFF66BB6A)
|
||||||
|
TestStatus.PARTIAL -> Color(0xFFFFB300)
|
||||||
|
TestStatus.FAILED -> Color(0xFFEF5350)
|
||||||
|
TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EcholotScreen(
|
||||||
|
state: UiState,
|
||||||
|
onRun: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onShizukuAction: () -> Unit,
|
||||||
|
onDeveloperOptions: () -> Unit,
|
||||||
|
onExport: (MeasurementDocument) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenHistory: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Android 15 draws edge-to-edge by default: without this the title runs under the
|
||||||
|
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
|
||||||
|
// AND the display cutout, so text never lands where it can't be read.
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("measure, don't guess",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onOpenHistory) { Text("History") }
|
||||||
|
TextButton(onClick = onOpenSettings) { Text("Settings") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
|
||||||
|
// only users who actually use it get reminded that it must be running.
|
||||||
|
state.shizukuNotice?.let { notice ->
|
||||||
|
Card(
|
||||||
|
Modifier.fillMaxWidth().let { m ->
|
||||||
|
if (state.shizukuHint != null) m.clickable { onShizukuAction() } else m
|
||||||
|
},
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (state.shizukuReady) Color(0xFF14301F) else Color(0xFF3A2E12),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text(
|
||||||
|
notice, fontSize = 12.sp,
|
||||||
|
color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A),
|
||||||
|
)
|
||||||
|
state.shizukuHint?.let {
|
||||||
|
Text(it, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color(0xFFFFB454))
|
||||||
|
}
|
||||||
|
// Wireless debugging must be ON before Shizuku's wireless start works, and
|
||||||
|
// that screen (unlike Shizuku's pairing activity) is publicly launchable.
|
||||||
|
if (state.shizukuState ==
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Wireless debugging settings →",
|
||||||
|
Modifier.padding(top = 6.dp).clickable { onDeveloperOptions() },
|
||||||
|
fontSize = 11.sp, color = Color(0xFF35E0C4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = onRun, enabled = !state.running) {
|
||||||
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
|
}
|
||||||
|
if (state.running) {
|
||||||
|
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
state.document?.let { doc ->
|
||||||
|
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.archiveStatus?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.uploadStatus?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.running) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val frac = if (state.stepsTotal > 0)
|
||||||
|
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { frac },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
if (state.stepsTotal > 0)
|
||||||
|
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||||
|
if (state.etaSeconds > 0) {
|
||||||
|
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.document?.let { doc -> Results(doc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Results(doc: MeasurementDocument) {
|
||||||
|
val summary = doc.summary
|
||||||
|
if (summary != null) {
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowCategories(summary.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
RouterPanel(doc)
|
||||||
|
|
||||||
|
SectionTitle("Networks (${doc.networks.size})")
|
||||||
|
for (n in doc.networks) {
|
||||||
|
Text("• ${n.transport.name.lowercase()} ${n.iface ?: ""} — " +
|
||||||
|
n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" },
|
||||||
|
fontSize = 13.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle("Tests (${doc.tests.size})")
|
||||||
|
for (t in doc.tests) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Dot(statusColor(t.status))
|
||||||
|
Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||||
|
Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000
|
||||||
|
Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.findings.isNotEmpty()) {
|
||||||
|
SectionTitle("Findings (${doc.findings.size})")
|
||||||
|
for (f in doc.findings) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(f.title, fontWeight = FontWeight.Medium)
|
||||||
|
Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(f.description, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who advertises IPv6 here, and what box is it? Pulled from the link.ra_source evidence and shown
|
||||||
|
* up front — a rogue/misconfigured RA sender is a top cause of broken IPv6, and "some router" is
|
||||||
|
* not actionable without an identity.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RouterPanel(doc: MeasurementDocument) {
|
||||||
|
val test = doc.tests.firstOrNull { it.type == TestType.LINK_RA_SOURCE } ?: return
|
||||||
|
val nets = (test.evidence?.get("networks") as? kotlinx.serialization.json.JsonArray) ?: return
|
||||||
|
if (nets.isEmpty()) return
|
||||||
|
|
||||||
|
SectionTitle("Router / IPv6 advertiser")
|
||||||
|
for (el in nets) {
|
||||||
|
val o = el as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
|
fun f(k: String): String? =
|
||||||
|
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||||
|
val ra = f("ra_source")
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(f("network") ?: "network", fontWeight = FontWeight.Medium)
|
||||||
|
// The identity line: vendor/model if we could pin it down.
|
||||||
|
val identity = listOfNotNull(
|
||||||
|
f("upnp_manufacturer"), f("upnp_model"), f("ra_source_vendor"),
|
||||||
|
).distinct().joinToString(" · ").ifBlank { null }
|
||||||
|
identity?.let {
|
||||||
|
Text(it, fontWeight = FontWeight.SemiBold, color = Color(0xFF35E0C4), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
f("upnp_friendly_name")?.let { Row0("name", it) }
|
||||||
|
ra?.let { Row0("RA source", it) }
|
||||||
|
f("ra_source_mac")?.let { Row0("RA source MAC", it) }
|
||||||
|
f("ra_source_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("reverse DNS", it) }
|
||||||
|
f("v4_gateway")?.let { Row0("IPv4 gateway", it) }
|
||||||
|
f("v4_gateway_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("gateway rDNS", it) }
|
||||||
|
f("upnp_server")?.let { Row0("UPnP server", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Row0(label: String, value: String) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("$label:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for ((name, cat) in categories) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Dot(verdictColor(cat.verdict))
|
||||||
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
|
Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "",
|
||||||
|
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dot(color: Color) {
|
||||||
|
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the exact JSON an upload would send.
|
||||||
|
*
|
||||||
|
* This exists because an anonymizer the user cannot inspect is just a promise. Being able to
|
||||||
|
* read the outgoing document — and find their own SSID absent from it — is what makes the
|
||||||
|
* privacy setting checkable rather than merely stated.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
|
||||||
|
title = { Text("This is what would be uploaded") },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
|
||||||
|
Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */
|
||||||
|
object Report {
|
||||||
|
private val json = Json { prettyPrint = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
fun toJson(doc: MeasurementDocument): String =
|
||||||
|
json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
|
||||||
|
fun share(ctx: Context, doc: MeasurementDocument): Intent =
|
||||||
|
shareJson(ctx, doc.run.id, toJson(doc))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shares an already-serialized run — an archived one, whose bytes must go out exactly as
|
||||||
|
* stored rather than being re-serialized through the model (which would silently drop
|
||||||
|
* anything a newer schema version added).
|
||||||
|
*/
|
||||||
|
fun shareJson(ctx: Context, runId: String, json: String): Intent {
|
||||||
|
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
|
||||||
|
val file = File(dir, "echolot-run-$runId.json")
|
||||||
|
file.writeText(json)
|
||||||
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||||
|
return Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/json"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
|
||||||
|
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
|
||||||
|
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
|
||||||
|
*/
|
||||||
|
object ReportUploader {
|
||||||
|
|
||||||
|
data class Result(val ok: Boolean, val detail: String)
|
||||||
|
|
||||||
|
fun upload(doc: MeasurementDocument): Result {
|
||||||
|
val url = BuildConfig.REPORT_UPLOAD_URL
|
||||||
|
if (url.isBlank()) return Result(false, "no upload URL configured")
|
||||||
|
return try {
|
||||||
|
val body = Report.toJson(doc).toByteArray()
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
|
||||||
|
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||||
|
?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
conn.disconnect()
|
||||||
|
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result(false, t.message ?: t.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.archive.ArchivedRun
|
||||||
|
import app.echo_lot.archive.RunArchive
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import app.echo_lot.privacy.Anonymizer
|
||||||
|
import app.echo_lot.privacy.PrivacyLevel
|
||||||
|
import app.echo_lot.privacy.Salt
|
||||||
|
import app.echo_lot.protocol.Compat
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.UploadRefused
|
||||||
|
import app.echo_lot.protocol.VersionRefused
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import java.io.File
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ties together the three things that happen to a finished run: it gets archived, it may get
|
||||||
|
* anonymized, and it may get uploaded — in that order, and with the archive always holding the
|
||||||
|
* *unredacted* document.
|
||||||
|
*
|
||||||
|
* That ordering is the important decision. The local archive is the user's own data on their own
|
||||||
|
* device, and redacting it would destroy exactly the detail that makes a week-old run worth
|
||||||
|
* keeping; the anonymizer exists for the moment data crosses to someone else's machine. So
|
||||||
|
* redaction happens on the way out, per upload, and the archive is never the lossy copy.
|
||||||
|
*/
|
||||||
|
class RunStore(context: Context, private val settings: Settings) {
|
||||||
|
|
||||||
|
private val archive = RunArchive(File(context.filesDir, "runs"))
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
fun list(): List<ArchivedRun> = archive.list()
|
||||||
|
fun read(id: String): String? = archive.read(id)
|
||||||
|
fun delete(id: String) = archive.delete(id)
|
||||||
|
fun deleteAll(): Int = archive.deleteAll()
|
||||||
|
fun totalBytes(): Long = archive.totalBytes()
|
||||||
|
|
||||||
|
/** Archives a finished run under the user's retention policy. Null when archiving is off. */
|
||||||
|
fun archive(doc: MeasurementDocument): ArchivedRun? =
|
||||||
|
archive.save(Report.toJson(doc), settings.retention())
|
||||||
|
|
||||||
|
/** Applies retention now — e.g. after the user tightens the limits in settings. */
|
||||||
|
fun purgeNow() = archive.purge(settings.retention())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces exactly the bytes an upload would send, so the UI can show the user their own
|
||||||
|
* document as the server will see it *before* it goes. "Preview what you're about to share"
|
||||||
|
* is the only honest way to present an anonymizer: its correctness is not something a user
|
||||||
|
* should have to take on faith.
|
||||||
|
*/
|
||||||
|
fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String {
|
||||||
|
val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull()
|
||||||
|
?: return docJson
|
||||||
|
return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun salt(): Salt =
|
||||||
|
if (settings.stableSalt) Salt.stable(settings.saltSecret())
|
||||||
|
else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) })
|
||||||
|
|
||||||
|
sealed interface UploadOutcome {
|
||||||
|
data class Sent(val serverName: String, val detail: String) : UploadOutcome
|
||||||
|
/** The operator's policy says no. Not retryable, and not the user's fault. */
|
||||||
|
data class Refused(val reason: String) : UploadOutcome
|
||||||
|
/**
|
||||||
|
* The two builds do not go together. Kept apart from [Refused] and [Failed] because the
|
||||||
|
* remedy is different and specific — install a particular version — and a message that
|
||||||
|
* says so is worth more than one that says "upload failed".
|
||||||
|
*/
|
||||||
|
data class Incompatible(val reason: String) : UploadOutcome
|
||||||
|
data class Failed(val detail: String) : UploadOutcome
|
||||||
|
data object NotConfigured : UploadOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun client() = ControlClient(
|
||||||
|
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the configured server without uploading anything: reachable, pinned, compatible, and
|
||||||
|
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
|
||||||
|
*/
|
||||||
|
fun checkServer(): String {
|
||||||
|
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
||||||
|
return try {
|
||||||
|
val profile = client().profile(settings.serverCredential)
|
||||||
|
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||||
|
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||||
|
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||||
|
when {
|
||||||
|
compat.message != null -> head + "\n" + compat.message
|
||||||
|
else -> {
|
||||||
|
val uploads = profile.uploads.refusalReason()
|
||||||
|
?: "uploads accepted (min anonymization: ${profile.uploads.minAnonymization})"
|
||||||
|
head + "\nCompatible. " + uploads
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
"This server will not serve this app: ${e.message}"
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
"Could not reach the server: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redeems an enrollment link and stores the resulting server configuration (§2.1).
|
||||||
|
*
|
||||||
|
* Everything is written at once or not at all: a half-applied server — say a URL and pin with
|
||||||
|
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
|
||||||
|
* Blocking; callers run it off the main thread.
|
||||||
|
*/
|
||||||
|
fun enroll(link: String, deviceName: String?): String {
|
||||||
|
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
|
||||||
|
?: return "That does not look like an Echolot enrollment link. It should start with " +
|
||||||
|
"echolot://enroll and carry a URL, a pin and a token."
|
||||||
|
return try {
|
||||||
|
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
||||||
|
val compat = Compat.check(enrolled.profile, BuildConfig.APP_SEMVER)
|
||||||
|
settings.serverUrl = enrolled.controlUrl
|
||||||
|
settings.serverPin = enrolled.pin
|
||||||
|
settings.serverCredential = enrolled.credential
|
||||||
|
val head = "Enrolled with ${enrolled.profile.name} " +
|
||||||
|
"(server ${enrolled.profile.serverVersion})."
|
||||||
|
if (compat.message != null) head + " " + compat.message else head
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
"That server will not serve this app: ${e.message}"
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
// The commonest causes are a spent token and a wrong pin, and they look nothing alike
|
||||||
|
// in the message — so pass it through rather than flattening it to "enrollment failed".
|
||||||
|
"Enrollment failed: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads one archived run to the configured server, redacting first.
|
||||||
|
*
|
||||||
|
* The server's advertised minimum wins over the user's preference when it is stricter — a
|
||||||
|
* server may demand more anonymization than the user chose, never less. Blocking; callers
|
||||||
|
* run it off the main thread.
|
||||||
|
*/
|
||||||
|
fun upload(runId: String): UploadOutcome {
|
||||||
|
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
|
||||||
|
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
|
||||||
|
return try {
|
||||||
|
val client = client()
|
||||||
|
val profile = client.profile(settings.serverCredential)
|
||||||
|
|
||||||
|
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||||
|
// policy it would never actually apply to us.
|
||||||
|
val compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||||
|
if (!compat.usable) return UploadOutcome.Incompatible(compat.message ?: "incompatible versions")
|
||||||
|
|
||||||
|
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
|
||||||
|
|
||||||
|
val level = PrivacyLevel.max(
|
||||||
|
settings.privacyLevel,
|
||||||
|
PrivacyLevel.fromWire(profile.uploads.minAnonymization),
|
||||||
|
)
|
||||||
|
val body = redactedForUpload(docJson, level)
|
||||||
|
val reply = client.uploadRun(settings.serverCredential, body)
|
||||||
|
archive.markUploaded(runId, profile.name)
|
||||||
|
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}")
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
UploadOutcome.Incompatible(e.message ?: "the server refused this app's version")
|
||||||
|
} catch (e: UploadRefused) {
|
||||||
|
UploadOutcome.Refused(e.message ?: "refused by the server")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
UploadOutcome.Failed(t.message ?: t.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.probe.CaptivePortalProbe
|
||||||
|
import app.echo_lot.probe.DnsCanaryProbe
|
||||||
|
import app.echo_lot.probe.IcmpProbe
|
||||||
|
import app.echo_lot.probe.LinkSnapshotProbe
|
||||||
|
import app.echo_lot.probe.StunProbe
|
||||||
|
import app.echo_lot.probe.NetworkInventory
|
||||||
|
import app.echo_lot.probe.Probe
|
||||||
|
import app.echo_lot.probe.ProbeIds
|
||||||
|
import app.echo_lot.probe.RouterIdentityProbe
|
||||||
|
import app.echo_lot.shizuku.ShizukuAvailability
|
||||||
|
import app.echo_lot.shizuku.ShizukuProbe
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val running: Boolean = false,
|
||||||
|
val currentStep: String? = null,
|
||||||
|
val document: MeasurementDocument? = null,
|
||||||
|
/** Result line of an automated upload (autorun mode); null when not attempted. */
|
||||||
|
val uploadStatus: String? = null,
|
||||||
|
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
|
||||||
|
val stepsDone: Int = 0,
|
||||||
|
val stepsTotal: Int = 0,
|
||||||
|
val etaSeconds: Int = 0,
|
||||||
|
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
||||||
|
val archiveStatus: String? = null,
|
||||||
|
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
||||||
|
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
|
||||||
|
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
|
||||||
|
val shizukuNotice: String? = null,
|
||||||
|
val shizukuReady: Boolean = false,
|
||||||
|
val shizukuHint: String? = null,
|
||||||
|
val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;
|
||||||
|
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
|
||||||
|
* survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a
|
||||||
|
* follow-up once enrollment UI lands.
|
||||||
|
*/
|
||||||
|
class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
var state by mutableStateOf(UiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
val settings = Settings(app)
|
||||||
|
private val store = RunStore(app, settings)
|
||||||
|
|
||||||
|
private var runJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val stopShizukuObserver: () -> Unit
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Report shell-tier readiness up front. The binder arrives asynchronously, so this is a
|
||||||
|
// listener, not a one-shot poll — otherwise a running Shizuku would look "not running"
|
||||||
|
// for the first moment after launch.
|
||||||
|
stopShizukuObserver = ShizukuAvailability.observe(app) { st ->
|
||||||
|
state = state.copy(
|
||||||
|
shizukuNotice = ShizukuAvailability.describe(st),
|
||||||
|
shizukuReady = st == ShizukuAvailability.State.READY,
|
||||||
|
shizukuHint = ShizukuAvailability.actionHint(st),
|
||||||
|
shizukuState = st,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
stopShizukuObserver()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
// Results collected so far. A cancelled run must still be able to show what it measured.
|
||||||
|
private val collected = mutableListOf<Test>()
|
||||||
|
private var runIds: RunIds = RunIds()
|
||||||
|
private var runStartWall: String = ""
|
||||||
|
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
|
private var runShizukuOk = false
|
||||||
|
|
||||||
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
|
private class RunIds : ProbeIds {
|
||||||
|
val originNanos = System.nanoTime()
|
||||||
|
override fun uuid(): String = UUID.randomUUID().toString()
|
||||||
|
override fun monoNs(): Long = System.nanoTime() - originNanos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one measurement, then archives it and — if the user has turned that on — uploads it.
|
||||||
|
*
|
||||||
|
* [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart
|
||||||
|
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
||||||
|
* silently satisfy, the consent-gated one.
|
||||||
|
*/
|
||||||
|
fun run(devUpload: Boolean = false) {
|
||||||
|
if (state.running) return
|
||||||
|
collected.clear()
|
||||||
|
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||||
|
uploadStatus = null, archiveStatus = null)
|
||||||
|
runJob = viewModelScope.launch {
|
||||||
|
val doc = withContext(Dispatchers.IO) { measure() }
|
||||||
|
|
||||||
|
step("archiving")
|
||||||
|
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
||||||
|
var archiveStatus = if (archived != null) {
|
||||||
|
"archived locally (${store.list().size} runs kept)"
|
||||||
|
} else {
|
||||||
|
"not archived — archiving is off in settings"
|
||||||
|
}
|
||||||
|
|
||||||
|
var status: String? = null
|
||||||
|
if (devUpload) {
|
||||||
|
state = state.copy(currentStep = "uploading report")
|
||||||
|
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||||
|
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
||||||
|
}
|
||||||
|
if (archived != null && settings.autoUpload) {
|
||||||
|
state = state.copy(currentStep = "uploading to server")
|
||||||
|
val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) }
|
||||||
|
archiveStatus += " · " + describe(outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
state = UiState(
|
||||||
|
running = false, currentStep = null, document = doc,
|
||||||
|
uploadStatus = status, archiveStatus = archiveStatus,
|
||||||
|
history = withContext(Dispatchers.IO) { store.list() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
|
||||||
|
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
|
||||||
|
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
|
||||||
|
is RunStore.UploadOutcome.Incompatible -> "version mismatch: ${o.reason}"
|
||||||
|
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
|
||||||
|
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- history ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
fun refreshHistory() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteRun(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { store.delete(id) }
|
||||||
|
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAllRuns() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val n = withContext(Dispatchers.IO) { store.deleteAll() }
|
||||||
|
state = state.copy(
|
||||||
|
history = emptyList(),
|
||||||
|
archiveStatus = "deleted $n archived run(s)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Uploads one already-archived run on demand, regardless of the auto-upload setting. */
|
||||||
|
fun uploadRun(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(archiveStatus = "uploading …")
|
||||||
|
val outcome = withContext(Dispatchers.IO) { store.upload(id) }
|
||||||
|
state = state.copy(
|
||||||
|
archiveStatus = describe(outcome),
|
||||||
|
history = withContext(Dispatchers.IO) { store.list() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The archived document as stored, for export. */
|
||||||
|
suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) }
|
||||||
|
|
||||||
|
/** The exact bytes an upload would send, for the settings screen's preview. */
|
||||||
|
suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
store.read(id)?.let { store.redactedForUpload(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun archivedBytes(): Long = store.totalBytes()
|
||||||
|
|
||||||
|
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
|
||||||
|
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(archiveStatus = "enrolling …")
|
||||||
|
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.enroll(link, deviceName) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings-screen action: report what the configured server is and whether we can use it. */
|
||||||
|
fun checkServer() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(archiveStatus = "checking server …")
|
||||||
|
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.checkServer() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-applies retention after the user changes the limits. */
|
||||||
|
fun applyRetention() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { store.purgeNow() }
|
||||||
|
state = state.copy(
|
||||||
|
history = withContext(Dispatchers.IO) { store.list() },
|
||||||
|
archiveStatus = if (result.isEmpty) "nothing to purge"
|
||||||
|
else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
|
||||||
|
* a partial run is for the person looking at the screen, not for the record.
|
||||||
|
*/
|
||||||
|
fun cancel() {
|
||||||
|
if (!state.running) return
|
||||||
|
runJob?.cancel()
|
||||||
|
val doc = buildDocument(collected.toList())
|
||||||
|
state = UiState(
|
||||||
|
running = false, currentStep = null, document = doc,
|
||||||
|
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||||
|
archiveStatus = "partial run — not archived",
|
||||||
|
history = state.history,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measure(): MeasurementDocument {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val ids = RunIds().also { runIds = it }
|
||||||
|
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||||
|
|
||||||
|
step("reading networks")
|
||||||
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
|
val networks = entries.map { it.model }.also { runNetworks = it }
|
||||||
|
|
||||||
|
val probes: List<Probe> = listOf(
|
||||||
|
LinkSnapshotProbe(entries),
|
||||||
|
RouterIdentityProbe(entries),
|
||||||
|
IcmpProbe(entries, v6 = false),
|
||||||
|
IcmpProbe(entries, v6 = true),
|
||||||
|
CaptivePortalProbe(entries),
|
||||||
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
|
// the reference deployment until profiles/enrollment land in the UI.
|
||||||
|
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
|
||||||
|
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
|
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
|
||||||
|
val shizukuEstimateMs = 8_000L
|
||||||
|
val totalSteps = probes.size + 1
|
||||||
|
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
|
||||||
|
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
||||||
|
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
||||||
|
|
||||||
|
val tests = ArrayList<Test>()
|
||||||
|
for ((i, p) in probes.withIndex()) {
|
||||||
|
step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
|
||||||
|
val result = (
|
||||||
|
try {
|
||||||
|
p.run(ctx, ids)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Test(
|
||||||
|
id = ids.uuid(), type = p.type, tier = p.tier,
|
||||||
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED,
|
||||||
|
error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tests.add(result); collected.add(result)
|
||||||
|
remainingMs -= p.estimatedMs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
|
||||||
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
|
val shizukuTest = try {
|
||||||
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Test(
|
||||||
|
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
|
||||||
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tests.add(shizukuTest); collected.add(shizukuTest)
|
||||||
|
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
||||||
|
|
||||||
|
return buildDocument(tests)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
||||||
|
private fun buildDocument(tests: List<Test>): MeasurementDocument {
|
||||||
|
val findings = deriveFindings(tests, runNetworks)
|
||||||
|
return MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
||||||
|
endedAt = Instant.now().toString(),
|
||||||
|
clock = Clock(monoOriginWall = runStartWall),
|
||||||
|
app = AppInfo(
|
||||||
|
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
|
||||||
|
),
|
||||||
|
device = DeviceInfo(
|
||||||
|
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
|
||||||
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
|
),
|
||||||
|
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||||
|
),
|
||||||
|
networks = runNetworks,
|
||||||
|
tests = tests,
|
||||||
|
findings = findings,
|
||||||
|
summary = Verdicts.derive(tests, findings),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
||||||
|
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
||||||
|
*/
|
||||||
|
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
|
||||||
|
networks.any { n ->
|
||||||
|
n.link.addresses.any { a ->
|
||||||
|
a.addr.contains(':') &&
|
||||||
|
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
||||||
|
!a.addr.startsWith("::1")
|
||||||
|
} || n.link.routes.any { it.dst == "::/0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
|
||||||
|
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
||||||
|
val out = ArrayList<Finding>()
|
||||||
|
val ids = RunIds()
|
||||||
|
for (t in tests) {
|
||||||
|
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
when {
|
||||||
|
ev.contains("\"captive_portal\"") -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "Captive portal intercepting connections",
|
||||||
|
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
t.status == TestStatus.FAILED -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY,
|
||||||
|
severity = Severity.HIGH, confidence = Confidence.HIGH,
|
||||||
|
title = "No working internet on any network",
|
||||||
|
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.DNS_CANARY) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
if (ev.contains("MISMATCH")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS,
|
||||||
|
severity = Severity.HIGH, confidence = Confidence.HIGH,
|
||||||
|
title = "DNS answers are being rewritten",
|
||||||
|
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (ev.contains("\"reached_authoritative\":false")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM,
|
||||||
|
title = "Canary queries don't reach the authoritative server",
|
||||||
|
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.NAT_STUN_5780 && t.status == TestStatus.OK) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
if (ev.contains("address/port-dependent (symmetric NAT")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "Symmetric NAT — peer-to-peer connections need a relay",
|
||||||
|
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||||
|
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
|
||||||
|
// and that is not a defect. What IS a defect is IPv6 that the network claims to
|
||||||
|
// provide (a global address or a default route from RA/DHCPv6) but that does not
|
||||||
|
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
|
||||||
|
// depends on whether v6 was provisioned at all.
|
||||||
|
if (ipv6Provisioned(networks)) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "IPv6 is configured but not working",
|
||||||
|
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
|
||||||
|
severity = Severity.INFO, confidence = Confidence.HIGH,
|
||||||
|
title = "IPv4-only network (no IPv6 offered)",
|
||||||
|
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
|
||||||
|
state = state.copy(
|
||||||
|
currentStep = s, stepsDone = done, stepsTotal = total,
|
||||||
|
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import app.echo_lot.archive.RetentionPolicy
|
||||||
|
import app.echo_lot.privacy.PrivacyLevel
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User settings for archiving, uploading and anonymization.
|
||||||
|
*
|
||||||
|
* Defaults are the conservative reading of "an engineer's tool that still respects the person
|
||||||
|
* holding it": keep history (that is the point of the archive), never upload without being asked,
|
||||||
|
* and when uploading, strip identifiers unless the user says this is their own server.
|
||||||
|
*
|
||||||
|
* SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at
|
||||||
|
* the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing.
|
||||||
|
*/
|
||||||
|
class Settings(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ---- archive ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
var archiveEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(ARCHIVE_ENABLED, true)
|
||||||
|
set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply()
|
||||||
|
|
||||||
|
/** 0 = no ceiling. */
|
||||||
|
var maxRuns: Int
|
||||||
|
get() = prefs.getInt(MAX_RUNS, 100)
|
||||||
|
set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply()
|
||||||
|
|
||||||
|
var maxAgeDays: Int
|
||||||
|
get() = prefs.getInt(MAX_AGE_DAYS, 90)
|
||||||
|
set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply()
|
||||||
|
|
||||||
|
var maxTotalMb: Int
|
||||||
|
get() = prefs.getInt(MAX_TOTAL_MB, 64)
|
||||||
|
set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply()
|
||||||
|
|
||||||
|
fun retention(): RetentionPolicy = RetentionPolicy(
|
||||||
|
enabled = archiveEnabled,
|
||||||
|
maxRuns = maxRuns,
|
||||||
|
maxAgeDays = maxAgeDays,
|
||||||
|
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- upload ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Off by default. Measurement data describes the network the user is standing in; sending it
|
||||||
|
* anywhere is a decision they make, not one they discover after the fact.
|
||||||
|
*/
|
||||||
|
var autoUpload: Boolean
|
||||||
|
get() = prefs.getBoolean(AUTO_UPLOAD, false)
|
||||||
|
set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply()
|
||||||
|
|
||||||
|
/** Anonymization applied before a run leaves the device. Never applied to the local archive. */
|
||||||
|
var privacyLevel: PrivacyLevel
|
||||||
|
get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire))
|
||||||
|
set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as
|
||||||
|
* last week") and is what someone wants on their own server — but it also produces an
|
||||||
|
* identifier that links a device's uploads, so it is off unless chosen.
|
||||||
|
*/
|
||||||
|
var stableSalt: Boolean
|
||||||
|
get() = prefs.getBoolean(STABLE_SALT, false)
|
||||||
|
set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device-local secret behind stable pseudonyms. Generated once, never leaves the device,
|
||||||
|
* and clearing it (via [resetSalt]) breaks the link to everything uploaded before.
|
||||||
|
*/
|
||||||
|
fun saltSecret(): ByteArray {
|
||||||
|
prefs.getString(SALT_SECRET, null)?.let { return hex(it) }
|
||||||
|
val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) }
|
||||||
|
prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply()
|
||||||
|
return fresh
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply()
|
||||||
|
|
||||||
|
// ---- server ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
var serverUrl: String
|
||||||
|
get() = prefs.getString(SERVER_URL, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply()
|
||||||
|
|
||||||
|
var serverPin: String
|
||||||
|
get() = prefs.getString(SERVER_PIN, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
|
||||||
|
|
||||||
|
var serverCredential: String
|
||||||
|
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
||||||
|
|
||||||
|
val serverConfigured: Boolean
|
||||||
|
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
|
||||||
|
|
||||||
|
private fun hex(s: String) = ByteArray(s.length / 2) {
|
||||||
|
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ARCHIVE_ENABLED = "archive_enabled"
|
||||||
|
const val MAX_RUNS = "archive_max_runs"
|
||||||
|
const val MAX_AGE_DAYS = "archive_max_age_days"
|
||||||
|
const val MAX_TOTAL_MB = "archive_max_total_mb"
|
||||||
|
const val AUTO_UPLOAD = "auto_upload"
|
||||||
|
const val PRIVACY_LEVEL = "privacy_level"
|
||||||
|
const val STABLE_SALT = "stable_salt"
|
||||||
|
const val SALT_SECRET = "salt_secret"
|
||||||
|
const val SERVER_URL = "server_url"
|
||||||
|
const val SERVER_PIN = "server_pin"
|
||||||
|
const val SERVER_CRED = "server_credential"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.echo_lot.privacy.PrivacyLevel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archiving, upload and anonymization settings.
|
||||||
|
*
|
||||||
|
* The screen is written to make the consequences legible rather than to look tidy: every toggle
|
||||||
|
* says what it means for the user's data in a sentence, and the privacy levels are described by
|
||||||
|
* what survives them, because "balanced" on its own tells nobody anything.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
settings: Settings,
|
||||||
|
archivedRuns: Int,
|
||||||
|
archivedBytes: Long,
|
||||||
|
onApplyRetention: () -> Unit,
|
||||||
|
onDeleteAll: () -> Unit,
|
||||||
|
onPreviewUpload: () -> Unit,
|
||||||
|
onCheckServer: () -> Unit,
|
||||||
|
onEnroll: (String) -> Unit,
|
||||||
|
serverStatus: String?,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||||
|
// through on change. A dozen scalars; a store with flows would be ceremony for nothing.
|
||||||
|
var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) }
|
||||||
|
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
||||||
|
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
||||||
|
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
||||||
|
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||||
|
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||||
|
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||||
|
var enrollLink by remember { mutableStateOf("") }
|
||||||
|
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
|
||||||
|
var serverPin by remember { mutableStateOf(settings.serverPin) }
|
||||||
|
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
TextButton(onClick = onBack) { Text("‹ Back") }
|
||||||
|
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- archive ----
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Archive", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Toggle(
|
||||||
|
label = "Keep finished runs on this device",
|
||||||
|
detail = "History is what makes a run comparable later. Archived runs are " +
|
||||||
|
"stored complete and unredacted — anonymization only applies to uploads.",
|
||||||
|
checked = archiveEnabled,
|
||||||
|
) { archiveEnabled = it; settings.archiveEnabled = it }
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"Purge automatically when a run exceeds any of these. 0 turns that limit off.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
NumberField("Keep at most (runs)", maxRuns) {
|
||||||
|
maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
NumberField("Delete older than (days)", maxAgeDays) {
|
||||||
|
maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
NumberField("Keep at most (MB)", maxTotalMb) {
|
||||||
|
maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"$archivedRuns run(s), ${archivedBytes / 1024} kB stored",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Button(onClick = onApplyRetention) { Text("Apply now") }
|
||||||
|
TextButton(onClick = onDeleteAll) { Text("Delete all runs") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- privacy ----
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("What leaves the device", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"Applied to uploads only. Measurements, verdicts and finding codes survive " +
|
||||||
|
"every level — only the parts that identify you or your network change.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
for (level in PrivacyLevel.entries) {
|
||||||
|
FilterChip(
|
||||||
|
selected = privacy == level,
|
||||||
|
onClick = { privacy = level; settings.privacyLevel = level },
|
||||||
|
label = { Text(level.wire) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
|
||||||
|
|
||||||
|
Toggle(
|
||||||
|
label = "Stable pseudonyms across runs",
|
||||||
|
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
|
||||||
|
"each time). It also links your uploads together, so leave it off on a " +
|
||||||
|
"server you don't run yourself.",
|
||||||
|
checked = stableSalt,
|
||||||
|
) { stableSalt = it; settings.stableSalt = it }
|
||||||
|
|
||||||
|
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- upload ----
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Upload", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Toggle(
|
||||||
|
label = "Upload finished runs automatically",
|
||||||
|
detail = "Sends each completed run to the server below, anonymized to the " +
|
||||||
|
"level above. The server may require more anonymization than you chose; " +
|
||||||
|
"it can never require less.",
|
||||||
|
checked = autoUpload,
|
||||||
|
) { autoUpload = it; settings.autoUpload = it }
|
||||||
|
|
||||||
|
// Enrollment first, because it is the path that works: one link carries the
|
||||||
|
// URL, the pin and a single-use token. The three fields below exist for when
|
||||||
|
// someone has to reconstruct a configuration by hand, not as the normal route.
|
||||||
|
Text(
|
||||||
|
"Paste an enrollment link from your server operator, or scan its QR code. " +
|
||||||
|
"It fills in all three fields below. The link contains a one-time token — " +
|
||||||
|
"treat it like a password until it is used.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enrollLink, onValueChange = { enrollLink = it },
|
||||||
|
label = { Text("echolot://enroll?…") }, singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onEnroll(enrollLink)
|
||||||
|
enrollLink = "" // spent either way; leaving it around invites a retry
|
||||||
|
serverUrl = settings.serverUrl
|
||||||
|
serverPin = settings.serverPin
|
||||||
|
serverCred = settings.serverCredential
|
||||||
|
},
|
||||||
|
enabled = enrollLink.isNotBlank(),
|
||||||
|
) { Text("Enroll") }
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
|
||||||
|
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
|
||||||
|
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverCred,
|
||||||
|
onValueChange = { serverCred = it; settings.serverCredential = it },
|
||||||
|
label = { Text("Device credential") }, singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = onCheckServer) { Text("Check server") }
|
||||||
|
Text(
|
||||||
|
" " + if (settings.serverConfigured) "Configured."
|
||||||
|
else "Uploads stay off until all three fields are set.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Version compatibility is checked here rather than discovered mid-run: a server
|
||||||
|
// that will refuse this build should say so before a measurement is wasted.
|
||||||
|
serverStatus?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
|
||||||
|
"${app.echo_lot.protocol.Compat.PROTOCOL_VERSION}. It works with servers " +
|
||||||
|
"${app.echo_lot.protocol.Compat.serverRange}.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
|
||||||
|
PrivacyLevel.FULL ->
|
||||||
|
"Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " +
|
||||||
|
"as measured. Appropriate for a server you run yourself."
|
||||||
|
PrivacyLevel.BALANCED ->
|
||||||
|
"Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " +
|
||||||
|
"prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " +
|
||||||
|
"ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " +
|
||||||
|
"192.168.1.1 describes the topology and not the person."
|
||||||
|
PrivacyLevel.STRICT ->
|
||||||
|
"Only numbers: test results, metrics and finding codes. No network description, no raw " +
|
||||||
|
"evidence, no finding text. Nothing left can identify a network."
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(detail, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NumberField(label: String, value: String, onChange: (String) -> Unit) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) },
|
||||||
|
label = { Text(label) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Adaptive-icon background: the brand "tile" gradient (assets/branding). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:startX="54" android:startY="0" android:endX="54" android:endY="108"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF0E2433"/>
|
||||||
|
<item android:offset="1" android:color="#FF071522"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Adaptive-icon foreground: the Focus mark (assets/branding/icon-adaptive-foreground.svg),
|
||||||
|
SVG transform baked in so the art sits inside the 66dp safe circle. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:strokeColor="#FFB454" android:strokeAlpha="0.3" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M47.6,54.0 a6.4,6.4 0 1,0 12.8,0 a6.4,6.4 0 1,0 -12.8,0"/>
|
||||||
|
<path android:fillColor="#FFFFB454" android:pathData="M50.4,54.0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M42.0,48.4 V42.0 H48.4"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M59.6,42.0 H66.0 V48.4"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M66.0,59.6 V66.0 H59.6"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M48.4,66.0 H42.0 V59.6"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Echolot</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Echolot" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="reports" path="reports/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The on-device run archive: measurement documents on disk, with retention.
|
||||||
|
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
|
||||||
|
// — the part with edge cases — are unit-testable without a device.
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
|
tasks.test { useJUnitPlatform() }
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package archive keeps completed measurement runs on the device.
|
||||||
|
//
|
||||||
|
// The point of an archive is the second run: "this network was fine on Tuesday" is only
|
||||||
|
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
|
||||||
|
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
|
||||||
|
// job somebody remembers to write — every save enforces it.
|
||||||
|
//
|
||||||
|
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
|
||||||
|
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
|
||||||
|
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
|
||||||
|
// write never leaves a half-document that reads as real.
|
||||||
|
package app.echo_lot.archive
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Index entry for one archived run — enough for a history list without opening the documents. */
|
||||||
|
@Serializable
|
||||||
|
data class ArchivedRun(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
|
||||||
|
@SerialName("started_at") val startedAt: String? = null,
|
||||||
|
val verdict: String? = null,
|
||||||
|
@SerialName("finding_count") val findingCount: Int = 0,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||||
|
val anonymization: String = "full",
|
||||||
|
/** Whether this run has been accepted by a server, so history can show what is backed up. */
|
||||||
|
val uploaded: Boolean = false,
|
||||||
|
@SerialName("uploaded_to") val uploadedTo: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
|
||||||
|
* them. Zero disables that limit.
|
||||||
|
*
|
||||||
|
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
|
||||||
|
* a pattern ("it degrades every evening") without turning the phone into an archive of every
|
||||||
|
* network its owner ever walked past.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class RetentionPolicy(
|
||||||
|
/**
|
||||||
|
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
|
||||||
|
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
|
||||||
|
* a user who turns all the caps off ends up with an empty history.
|
||||||
|
*/
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
@SerialName("max_runs") val maxRuns: Int = 100,
|
||||||
|
@SerialName("max_age_days") val maxAgeDays: Int = 90,
|
||||||
|
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Archiving off: runs are shown once and never written. */
|
||||||
|
val KeepNothing = RetentionPolicy(enabled = false)
|
||||||
|
|
||||||
|
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
|
||||||
|
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
|
||||||
|
|
||||||
|
val Default = RetentionPolicy()
|
||||||
|
}
|
||||||
|
|
||||||
|
val keepsAnything: Boolean get() = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
|
||||||
|
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
|
||||||
|
val isEmpty: Boolean get() = removed.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
init {
|
||||||
|
dir.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes one run and applies retention. Returns the index entry, or null when the policy
|
||||||
|
* keeps nothing at all — in which case nothing is written, rather than written and instantly
|
||||||
|
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
|
||||||
|
*/
|
||||||
|
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
|
||||||
|
if (!policy.keepsAnything) return null
|
||||||
|
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
|
||||||
|
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
|
||||||
|
|
||||||
|
writeAtomically(File(dir, meta.id + EXT), runJson)
|
||||||
|
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
|
||||||
|
purge(policy)
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
/** History, newest first. */
|
||||||
|
fun list(): List<ArchivedRun> =
|
||||||
|
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
|
||||||
|
.mapNotNull { f ->
|
||||||
|
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.savedAtEpochMs }
|
||||||
|
|
||||||
|
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
|
||||||
|
|
||||||
|
fun delete(id: String): Boolean {
|
||||||
|
val s = safe(id)
|
||||||
|
val doc = File(dir, s + EXT).delete()
|
||||||
|
File(dir, s + META_EXT).delete()
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAll(): Int = list().count { delete(it.id) }
|
||||||
|
|
||||||
|
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
|
||||||
|
fun markUploaded(id: String, serverName: String) {
|
||||||
|
val f = File(dir, safe(id) + META_EXT)
|
||||||
|
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
|
||||||
|
?: return
|
||||||
|
writeAtomically(
|
||||||
|
f,
|
||||||
|
json.encodeToString(
|
||||||
|
ArchivedRun.serializer(),
|
||||||
|
meta.copy(uploaded = true, uploadedTo = serverName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
|
||||||
|
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
|
||||||
|
* should not be pre-empted by a size sweep deleting last week instead.
|
||||||
|
*/
|
||||||
|
fun purge(policy: RetentionPolicy): PurgeResult {
|
||||||
|
val removed = ArrayList<String>()
|
||||||
|
var freed = 0L
|
||||||
|
fun drop(r: ArchivedRun) {
|
||||||
|
if (delete(r.id)) {
|
||||||
|
removed.add(r.id)
|
||||||
|
freed += r.sizeBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept = list()
|
||||||
|
if (policy.maxAgeDays > 0) {
|
||||||
|
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
|
||||||
|
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
|
||||||
|
stale.forEach(::drop)
|
||||||
|
kept = fresh
|
||||||
|
}
|
||||||
|
if (policy.maxTotalBytes > 0) {
|
||||||
|
var total = kept.sumOf { it.sizeBytes }
|
||||||
|
// Oldest first until we are under the ceiling.
|
||||||
|
for (r in kept.reversed()) {
|
||||||
|
if (total <= policy.maxTotalBytes) break
|
||||||
|
drop(r)
|
||||||
|
total -= r.sizeBytes
|
||||||
|
}
|
||||||
|
kept = kept.filter { it.id !in removed }
|
||||||
|
}
|
||||||
|
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
|
||||||
|
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
|
||||||
|
}
|
||||||
|
return PurgeResult(removed, freed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- internals ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
|
||||||
|
val run = doc["run"]?.jsonObject ?: return null
|
||||||
|
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
return ArchivedRun(
|
||||||
|
id = id,
|
||||||
|
savedAtEpochMs = now(),
|
||||||
|
startedAt = run["started_at"]?.jsonPrimitive?.content,
|
||||||
|
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
|
||||||
|
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
|
||||||
|
sizeBytes = size,
|
||||||
|
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAtomically(target: File, content: String) {
|
||||||
|
val tmp = File(target.parentFile, target.name + ".tmp")
|
||||||
|
tmp.writeText(content)
|
||||||
|
if (!tmp.renameTo(target)) {
|
||||||
|
target.delete()
|
||||||
|
tmp.renameTo(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
|
||||||
|
private fun safe(id: String): String = buildString {
|
||||||
|
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
|
||||||
|
}.take(64)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EXT = ".json"
|
||||||
|
const val META_EXT = ".meta.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.archive
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class RunArchiveTest {
|
||||||
|
|
||||||
|
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
|
||||||
|
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
|
||||||
|
|
||||||
|
private fun archive() = RunArchive(dir) { clock }
|
||||||
|
|
||||||
|
@AfterTest fun cleanup() { dir.deleteRecursively() }
|
||||||
|
|
||||||
|
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
|
||||||
|
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
|
||||||
|
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
|
||||||
|
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun savedRunsComeBackNewestFirst() {
|
||||||
|
val a = archive()
|
||||||
|
for (i in 1..3) {
|
||||||
|
a.save(doc("run-$i"))
|
||||||
|
clock += 60_000
|
||||||
|
}
|
||||||
|
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theIndexSummarisesTheDocument() {
|
||||||
|
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
|
||||||
|
assertEquals("warn", meta.verdict)
|
||||||
|
assertEquals(4, meta.findingCount)
|
||||||
|
assertEquals("balanced", meta.anonymization)
|
||||||
|
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
|
||||||
|
assertFalse(meta.uploaded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDocumentComesBackByteForByte() {
|
||||||
|
val a = archive()
|
||||||
|
val original = doc("run-1")
|
||||||
|
a.save(original)
|
||||||
|
assertEquals(original, a.read("run-1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun countLimitKeepsTheNewest() {
|
||||||
|
val a = archive()
|
||||||
|
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
|
||||||
|
for (i in 1..7) {
|
||||||
|
a.save(doc("run-$i"), policy)
|
||||||
|
clock += 60_000
|
||||||
|
}
|
||||||
|
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
|
||||||
|
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ageLimitDropsRunsPastTheWindow() {
|
||||||
|
val a = archive()
|
||||||
|
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
|
||||||
|
a.save(doc("old"), policy)
|
||||||
|
clock += 30L * 24 * 60 * 60 * 1000 // a month later
|
||||||
|
a.save(doc("new"), policy)
|
||||||
|
assertEquals(listOf("new"), a.list().map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
|
||||||
|
val a = archive()
|
||||||
|
val one = doc("x", pad = 900).toByteArray().size.toLong()
|
||||||
|
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
|
||||||
|
for (i in 1..5) {
|
||||||
|
a.save(doc("run-$i", pad = 900), policy)
|
||||||
|
clock += 60_000
|
||||||
|
}
|
||||||
|
val kept = a.list()
|
||||||
|
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
|
||||||
|
assertEquals("run-5", kept.first().id, "the newest run must always survive")
|
||||||
|
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
|
||||||
|
@Test
|
||||||
|
fun keepNothingWritesNothing() {
|
||||||
|
val a = archive()
|
||||||
|
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
|
||||||
|
assertTrue(a.list().isEmpty())
|
||||||
|
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
|
||||||
|
// one: turning every limit off should keep everything, not wipe the history.
|
||||||
|
@Test
|
||||||
|
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
|
||||||
|
val a = archive()
|
||||||
|
for (i in 1..5) {
|
||||||
|
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
|
||||||
|
clock += 60_000
|
||||||
|
}
|
||||||
|
assertEquals(5, a.list().size)
|
||||||
|
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
|
||||||
|
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uploadStateIsRecorded() {
|
||||||
|
val a = archive()
|
||||||
|
a.save(doc("run-1"))
|
||||||
|
a.markUploaded("run-1", "fmr")
|
||||||
|
val meta = a.list().single()
|
||||||
|
assertTrue(meta.uploaded)
|
||||||
|
assertEquals("fmr", meta.uploadedTo)
|
||||||
|
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteRemovesBothFiles() {
|
||||||
|
val a = archive()
|
||||||
|
a.save(doc("run-1"))
|
||||||
|
assertTrue(a.delete("run-1"))
|
||||||
|
assertTrue(a.list().isEmpty())
|
||||||
|
assertNull(a.read("run-1"))
|
||||||
|
assertEquals(0, dir.listFiles()?.size ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedInputIsRejectedRatherThanArchived() {
|
||||||
|
val a = archive()
|
||||||
|
assertNull(a.save("not json"))
|
||||||
|
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
|
||||||
|
assertTrue(a.list().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run ids come from a document that may have been produced elsewhere; they must not be able
|
||||||
|
// to write outside the archive directory.
|
||||||
|
@Test
|
||||||
|
fun runIdsCannotEscapeTheArchiveDirectory() {
|
||||||
|
val a = archive()
|
||||||
|
a.save(doc("../../evil"))
|
||||||
|
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
|
||||||
|
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun purgeReportsWhatItRemoved() {
|
||||||
|
val a = archive()
|
||||||
|
for (i in 1..5) {
|
||||||
|
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
|
||||||
|
clock += 60_000
|
||||||
|
}
|
||||||
|
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
|
||||||
|
assertEquals(3, result.removed.size)
|
||||||
|
assertTrue(result.freedBytes > 0)
|
||||||
|
assertEquals(2, a.list().size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measurement run engine: composes core-protocol probes into
|
||||||
|
// core-measurement documents. Pure Kotlin/JVM, so it is unit-testable and can
|
||||||
|
// run a full server-facing measurement against a live server.
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-protocol"))
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(project(":core-privacy"))
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET","ECHOLOT_ENROLL_URI")
|
||||||
|
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import app.echo_lot.protocol.Wire
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The measurements only the far end can make: what the *downstream* path does to traffic the
|
||||||
|
* client never asked for packet-by-packet.
|
||||||
|
*
|
||||||
|
* A client alone can measure a round trip, and it can find the largest packet it can *send*. It
|
||||||
|
* cannot find the largest packet it can *receive*, or whether the network drops downstream
|
||||||
|
* packets independently of upstream ones — those need a server willing to push, which is why the
|
||||||
|
* protocol gates them behind an asymmetric grant (probe-protocol.md §3.4).
|
||||||
|
*
|
||||||
|
* Three separate facts come out, and keeping them separate is the point:
|
||||||
|
* - `mtu.pmtud_down` — the largest datagram that arrives *unfragmented*. This is the number
|
||||||
|
* that matters for anything setting DF, and it is only meaningful because the server sets DF.
|
||||||
|
* - `mtu.frag_delivery` — whether larger datagrams arrive once the network is allowed to
|
||||||
|
* fragment them. A path can be fine for one and broken for the other; conflating them is how
|
||||||
|
* you get "MTU is 4000" on a link that drops every DF packet over 1400.
|
||||||
|
* - `train.udp_downstream` — loss, reordering and arrival spacing in the download direction.
|
||||||
|
*/
|
||||||
|
class DownstreamMeasurement(private val ids: IdSource) {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
/** How long to wait for a granted burst after the server accepts the action. */
|
||||||
|
private val collectWindowMs = 4_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs all three against an already-primed session.
|
||||||
|
*
|
||||||
|
* [session] must already have sent at least one ECHO: the grant is bound to the source the
|
||||||
|
* server has actually observed, so an unprimed session gets a 409 rather than a grant. That
|
||||||
|
* is the anti-amplification rule doing its job, not an error to work around.
|
||||||
|
*/
|
||||||
|
fun run(
|
||||||
|
credential: String,
|
||||||
|
sessionId: String,
|
||||||
|
control: ControlClient,
|
||||||
|
probe: ProbeSession,
|
||||||
|
sessionRef: String,
|
||||||
|
sizes: List<Int> = DEFAULT_SIZES,
|
||||||
|
trainCount: Int = 100,
|
||||||
|
trainSizeBytes: Int = 300,
|
||||||
|
trainIntervalUs: Int = 3_000,
|
||||||
|
): Pair<List<Test>, List<Finding>> {
|
||||||
|
val tests = ArrayList<Test>()
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
|
||||||
|
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
|
||||||
|
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
|
||||||
|
val train = downTrain(
|
||||||
|
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs,
|
||||||
|
)
|
||||||
|
|
||||||
|
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
|
||||||
|
|
||||||
|
// A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud:
|
||||||
|
// it is the usual cause of "small requests work, large responses hang".
|
||||||
|
val pathMtu = df.largestDelivered
|
||||||
|
if (pathMtu != null && pathMtu > 0) {
|
||||||
|
val ipMtu = pathMtu + IP_UDP_OVERHEAD4
|
||||||
|
if (ipMtu < 1500) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
"mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id,
|
||||||
|
"Downstream path MTU is $ipMtu bytes, below 1500",
|
||||||
|
"The largest datagram that reached this device without fragmenting was " +
|
||||||
|
"$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " +
|
||||||
|
"IPv6-in-IPv4) commonly do this; it is only a fault when something " +
|
||||||
|
"on the path also blocks the ICMP messages that let senders discover it.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The dangerous combination: unfragmented large packets vanish AND fragments do too,
|
||||||
|
// so a sender that never gets told will retransmit into a black hole.
|
||||||
|
val fragLargest = frag.largestDelivered ?: 0
|
||||||
|
if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id,
|
||||||
|
"Datagrams above $pathMtu bytes are dropped downstream, fragmented or not",
|
||||||
|
"Nothing larger than $pathMtu bytes arrived, even when the network was " +
|
||||||
|
"free to fragment it. Traffic that relies on large responses will " +
|
||||||
|
"stall rather than fail cleanly.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (train.received == 0) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id,
|
||||||
|
"No server-initiated packets arrived",
|
||||||
|
"The server sent ${train.sent} packets toward this device and none arrived, " +
|
||||||
|
"while the round-trip echo worked. Something on the path forwards replies " +
|
||||||
|
"but drops traffic the device did not individually solicit.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (train.lossPct >= 5.0) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
"connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id,
|
||||||
|
"Downstream loss of ${round1(train.lossPct)}%",
|
||||||
|
"${train.sent - train.received} of ${train.sent} packets sent toward this " +
|
||||||
|
"device were lost. Downstream loss is invisible to a round-trip test, " +
|
||||||
|
"which reports only that *something* was lost somewhere.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (train.reordered > 0) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id,
|
||||||
|
"${train.reordered} downstream packet(s) arrived out of order",
|
||||||
|
"Packets arrived in a different order than they were sent. Usually per-packet " +
|
||||||
|
"load balancing across links; harmless for most traffic, not for all of it.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return tests to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- big_send ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private class SizeResult(val test: Test, val largestDelivered: Int?)
|
||||||
|
|
||||||
|
private fun bigSend(
|
||||||
|
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
||||||
|
sessionRef: String, sizes: List<Int>, df: Boolean,
|
||||||
|
): SizeResult {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
val requested = sizes.joinToString(",")
|
||||||
|
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(
|
||||||
|
credential, sessionId,
|
||||||
|
"""{"action":"big_send","df":$df,"sizes_bytes":[$requested]}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reply.isFailure) {
|
||||||
|
return SizeResult(
|
||||||
|
Test(
|
||||||
|
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
|
||||||
|
sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.UNSUPPORTED,
|
||||||
|
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "big_send refused"),
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server tells us which sizes it actually put on the wire. With DF it refuses
|
||||||
|
// anything above its own egress MTU, and treating those as "lost downstream" would
|
||||||
|
// blame the client's network for our own limit.
|
||||||
|
val accepted = parseIntArray(reply.getOrNull(), "sizes_bytes").ifEmpty { sizes }
|
||||||
|
val serverMaxDf = parseInt(reply.getOrNull(), "max_df_bytes")
|
||||||
|
|
||||||
|
val arrived = probe.collectGranted(collectWindowMs)
|
||||||
|
.filter { it.type == Wire.TYPE_BIG_SEND }
|
||||||
|
.map { it.sizeBytes }
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
val largest = arrived.maxOrNull()
|
||||||
|
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
BigSendMetrics(
|
||||||
|
requestedBytes = sizes,
|
||||||
|
sentBytes = accepted,
|
||||||
|
deliveredBytes = arrived,
|
||||||
|
largestDeliveredBytes = largest,
|
||||||
|
dontFragment = df,
|
||||||
|
serverMaxDfBytes = serverMaxDf,
|
||||||
|
// Only meaningful for the DF run; the IP-level MTU is the payload plus headers.
|
||||||
|
pathMtuBytes = if (df && largest != null) largest + IP_UDP_OVERHEAD4 else null,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
arrived.isEmpty() -> TestStatus.FAILED
|
||||||
|
arrived.size < accepted.size -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
return SizeResult(
|
||||||
|
Test(
|
||||||
|
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
|
||||||
|
sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = status, metrics = metrics,
|
||||||
|
),
|
||||||
|
largest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- downtrain --------------------------------------------------------------------
|
||||||
|
|
||||||
|
private class TrainResult(
|
||||||
|
val test: Test, val sent: Int, val received: Int, val lossPct: Double, val reordered: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun downTrain(
|
||||||
|
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
||||||
|
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int,
|
||||||
|
): TrainResult {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(
|
||||||
|
credential, sessionId,
|
||||||
|
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reply.isFailure) {
|
||||||
|
return TrainResult(
|
||||||
|
Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef,
|
||||||
|
tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.UNSUPPORTED,
|
||||||
|
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "downtrain refused"),
|
||||||
|
),
|
||||||
|
0, 0, 0.0, 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val sent = parseInt(reply.getOrNull(), "count") ?: count
|
||||||
|
|
||||||
|
val got = probe.collectGranted(collectWindowMs).filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
|
||||||
|
val received = got.size
|
||||||
|
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||||
|
|
||||||
|
// Reordering: a packet whose sequence is below the highest already seen. Counting
|
||||||
|
// inversions rather than "not sorted" keeps one late packet from being reported as
|
||||||
|
// dozens of reorder events.
|
||||||
|
var highest = -1
|
||||||
|
var reordered = 0
|
||||||
|
for (p in got) {
|
||||||
|
if (p.seq < highest) reordered++ else highest = p.seq
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columnar evidence per the schema: what arrived, when, and how big — so every metric
|
||||||
|
// above is recomputable by a reader who does not trust our arithmetic.
|
||||||
|
val evidence = TrainEvidence(
|
||||||
|
epochMonoNs = started,
|
||||||
|
seq = got.map { it.seq },
|
||||||
|
tTxNs = got.map { null },
|
||||||
|
tRxNs = got.map { it.tRxNs },
|
||||||
|
sizeBytes = got.map { it.sizeBytes },
|
||||||
|
).toEvidence()
|
||||||
|
|
||||||
|
val interArrival = got.zipWithNext { a, b -> (b.tRxNs - a.tRxNs) / 1_000_000.0 }
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
DownTrainMetrics(
|
||||||
|
sent = sent, received = received, lossPct = round1(lossPct),
|
||||||
|
reorderedPackets = reordered,
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
|
||||||
|
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
|
||||||
|
sendIntervalUs = intervalUs,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
received == 0 -> TestStatus.FAILED
|
||||||
|
received < sent -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
return TrainResult(
|
||||||
|
Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = status, evidence = evidence, metrics = metrics,
|
||||||
|
),
|
||||||
|
sent, received, lossPct, reordered,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- helpers ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Minimal scalar extraction from the action reply; the shape is small and server-owned. */
|
||||||
|
private fun parseInt(body: String?, key: String): Int? =
|
||||||
|
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
||||||
|
|
||||||
|
private fun parseIntArray(body: String?, key: String): List<Int> =
|
||||||
|
body?.let { b ->
|
||||||
|
Regex("\"$key\"\\s*:\\s*\\[([^\\]]*)\\]").find(b)?.groupValues?.get(1)
|
||||||
|
?.split(",")?.mapNotNull { it.trim().toIntOrNull() }
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */
|
||||||
|
const val IP_UDP_OVERHEAD4 = 28
|
||||||
|
|
||||||
|
/** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */
|
||||||
|
val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000)
|
||||||
|
|
||||||
|
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metrics for mtu.pmtud_down / mtu.frag_delivery. */
|
||||||
|
@Serializable
|
||||||
|
data class BigSendMetrics(
|
||||||
|
@SerialName("requested_bytes") val requestedBytes: List<Int>,
|
||||||
|
@SerialName("sent_bytes") val sentBytes: List<Int>,
|
||||||
|
@SerialName("delivered_bytes") val deliveredBytes: List<Int>,
|
||||||
|
@SerialName("largest_delivered_bytes") val largestDeliveredBytes: Int? = null,
|
||||||
|
@SerialName("dont_fragment") val dontFragment: Boolean,
|
||||||
|
/** The server's own DF ceiling; sizes above it were never sent and are not path evidence. */
|
||||||
|
@SerialName("server_max_df_bytes") val serverMaxDfBytes: Int? = null,
|
||||||
|
@SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Metrics for train.udp_downstream. */
|
||||||
|
@Serializable
|
||||||
|
data class DownTrainMetrics(
|
||||||
|
val sent: Int,
|
||||||
|
val received: Int,
|
||||||
|
@SerialName("loss_pct") val lossPct: Double,
|
||||||
|
@SerialName("reordered_packets") val reorderedPackets: Int,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: Int,
|
||||||
|
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
|
||||||
|
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
|
||||||
|
@SerialName("send_interval_us") val sendIntervalUs: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package engine composes core-protocol probes into core-measurement documents — the run engine
|
||||||
|
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
|
||||||
|
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
|
||||||
|
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
|
||||||
|
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
|
||||||
|
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
|
||||||
|
* (no clocks/UUIDs of its own) and unit-testable.
|
||||||
|
*/
|
||||||
|
class ServerMeasurement(
|
||||||
|
private val ids: IdSource,
|
||||||
|
private val app: AppInfo,
|
||||||
|
private val device: DeviceInfo,
|
||||||
|
) {
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
data class Config(
|
||||||
|
val controlUrl: String,
|
||||||
|
val pins: Set<String>,
|
||||||
|
val credential: String,
|
||||||
|
val target: String,
|
||||||
|
val udpHost: String,
|
||||||
|
val udpPort: Int,
|
||||||
|
val echoCount: Int = 20,
|
||||||
|
val echoPaddingBytes: Int = 64,
|
||||||
|
/**
|
||||||
|
* Whether to ask the server to push traffic back (downstream MTU and downstream train).
|
||||||
|
* Costs a few hundred kB of download and needs a server that advertises the grants, so
|
||||||
|
* it is a flag rather than an assumption.
|
||||||
|
*/
|
||||||
|
val downstream: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun run(cfg: Config): MeasurementDocument {
|
||||||
|
val runId = ids.uuid()
|
||||||
|
val startWall = ids.nowWall()
|
||||||
|
val startMono = ids.monoNs()
|
||||||
|
|
||||||
|
val control = ControlClient(cfg.controlUrl, cfg.pins)
|
||||||
|
val profile = control.profile(cfg.credential)
|
||||||
|
val session = control.createSession(cfg.credential, cfg.target)
|
||||||
|
|
||||||
|
val serverSession = ServerSession(
|
||||||
|
id = "sess-1",
|
||||||
|
profileName = profile.name,
|
||||||
|
controlUrl = cfg.controlUrl,
|
||||||
|
serverVersion = profile.serverVersion,
|
||||||
|
capabilities = profile.capabilities,
|
||||||
|
sessionId = session.sessionId,
|
||||||
|
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
val tests = ArrayList<Test>()
|
||||||
|
val allFindings = ArrayList<Finding>()
|
||||||
|
|
||||||
|
// One ProbeSession for the whole run. A second one would open a new socket and restart
|
||||||
|
// the sequence counter, which the server's anti-replay window correctly rejects — so the
|
||||||
|
// re-primed source is never recorded and every granted send goes to the old, closed port.
|
||||||
|
// Session identity lives on the server; the socket must live as long as it does.
|
||||||
|
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
|
||||||
|
val (test, findings) = echoTrain(cfg, ps, startMono)
|
||||||
|
tests.add(test)
|
||||||
|
allFindings.addAll(findings)
|
||||||
|
|
||||||
|
// Downstream needs a session the server has already seen traffic from — the echo
|
||||||
|
// train just provided that — and a server that advertises the grants. Skipped
|
||||||
|
// quietly against an older server rather than reported as a failure of the network.
|
||||||
|
if (cfg.downstream && profile.supports("downtrain") && profile.supports("big-send")) {
|
||||||
|
val (dsTests, dsFindings) = DownstreamMeasurement(ids)
|
||||||
|
.run(cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1")
|
||||||
|
tests.addAll(dsTests)
|
||||||
|
allFindings.addAll(dsFindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
control.deleteSession(cfg.credential, session.sessionId)
|
||||||
|
|
||||||
|
val summary = Verdicts.derive(tests, allFindings)
|
||||||
|
return MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
|
||||||
|
clock = Clock(monoOriginWall = startWall),
|
||||||
|
app = app, device = device,
|
||||||
|
tiers = Tiers(app = true),
|
||||||
|
),
|
||||||
|
serverSessions = listOf(serverSession),
|
||||||
|
tests = tests,
|
||||||
|
findings = allFindings,
|
||||||
|
summary = summary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun echoTrain(
|
||||||
|
cfg: Config, ps: ProbeSession, startMono: Long,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val seqs = ArrayList<Int>()
|
||||||
|
val tTx = ArrayList<Long?>()
|
||||||
|
val tRx = ArrayList<Long?>()
|
||||||
|
val sizes = ArrayList<Int>()
|
||||||
|
val rtts = ArrayList<Double>()
|
||||||
|
val observedPorts = LinkedHashSet<Int>()
|
||||||
|
|
||||||
|
for (i in 0 until cfg.echoCount) {
|
||||||
|
val txMono = ids.monoNs() - startMono
|
||||||
|
val r = ps.echo(cfg.echoPaddingBytes)
|
||||||
|
seqs.add(i)
|
||||||
|
tTx.add(txMono)
|
||||||
|
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
|
||||||
|
if (r != null) {
|
||||||
|
tRx.add(ids.monoNs() - startMono)
|
||||||
|
rtts.add(r.rttMs)
|
||||||
|
r.observation?.observedPort?.let { observedPorts.add(it) }
|
||||||
|
} else {
|
||||||
|
tRx.add(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sent = cfg.echoCount
|
||||||
|
val received = rtts.size
|
||||||
|
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||||
|
val natRebinding = observedPorts.size > 1
|
||||||
|
|
||||||
|
val evidence: JsonObject = TrainEvidence(
|
||||||
|
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
|
||||||
|
).toEvidence()
|
||||||
|
|
||||||
|
val metrics: JsonObject = json.encodeToJsonElement(
|
||||||
|
EchoMetrics(
|
||||||
|
sent = sent, received = received, lossPct = round1(lossPct),
|
||||||
|
rttMsMin = rtts.minOrNull()?.let(::round1),
|
||||||
|
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
|
||||||
|
rttMsMax = rtts.maxOrNull()?.let(::round1),
|
||||||
|
observedPorts = observedPorts.toList(),
|
||||||
|
natRebindingDetected = natRebinding,
|
||||||
|
)
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
received == 0 -> TestStatus.FAILED
|
||||||
|
received < sent -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
val test = Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
|
||||||
|
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
|
||||||
|
evidence = evidence, metrics = metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
if (received == 0) {
|
||||||
|
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
|
||||||
|
"No UDP echo replies from the server",
|
||||||
|
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
|
||||||
|
} else if (lossPct >= 20.0) {
|
||||||
|
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
|
||||||
|
"High UDP loss to the server (${round1(lossPct)}%)",
|
||||||
|
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
|
||||||
|
}
|
||||||
|
if (natRebinding) {
|
||||||
|
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
|
||||||
|
"NAT remapped the UDP source port mid-flow",
|
||||||
|
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
|
||||||
|
}
|
||||||
|
return test to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val Wire_HEADER = 32
|
||||||
|
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
|
||||||
|
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
|
||||||
|
*/
|
||||||
|
interface IdSource {
|
||||||
|
fun uuid(): String
|
||||||
|
fun monoNs(): Long
|
||||||
|
fun nowWall(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemIdSource : IdSource {
|
||||||
|
override fun uuid(): String = UUID.randomUUID().toString()
|
||||||
|
override fun monoNs(): Long = System.nanoTime()
|
||||||
|
override fun nowWall(): String = Instant.now().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
|
||||||
|
@Serializable
|
||||||
|
data class EchoMetrics(
|
||||||
|
val sent: Int,
|
||||||
|
val received: Int,
|
||||||
|
@SerialName("loss_pct") val lossPct: Double,
|
||||||
|
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
|
||||||
|
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
|
||||||
|
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
|
||||||
|
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
|
||||||
|
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.Compat
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.VersionRefused
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the version gate against a LIVE server — the half that unit tests cannot reach, because
|
||||||
|
* the whole point is that two independently-built artifacts agree. Self-skips without
|
||||||
|
* ECHOLOT_LIVE_*.
|
||||||
|
*/
|
||||||
|
class LiveCompatTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
|
||||||
|
private fun clientAs(version: String) = ControlClient(url!!, setOf(pin!!), version)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theServerAdvertisesAndEnforcesItsWindow() {
|
||||||
|
if (url == null || pin == null || cred == null) {
|
||||||
|
println("LiveCompatTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The profile must state the window — without it the app cannot pre-empt a refusal.
|
||||||
|
val profile = clientAs("0.2.0").profile(cred)
|
||||||
|
println("server ${profile.serverVersion} protocol=${profile.compat.protocolVersion} " +
|
||||||
|
"accepts app [${profile.compat.appMin}, ${profile.compat.appMax})")
|
||||||
|
assertTrue(profile.compat.protocolVersion.isNotBlank(), "profile omits protocol_version")
|
||||||
|
assertTrue(profile.compat.appMin.isNotBlank(), "profile omits app_min")
|
||||||
|
|
||||||
|
// This build must be inside it, or every other live test here is meaningless.
|
||||||
|
val verdict = Compat.check(profile, "0.2.0")
|
||||||
|
assertEquals(Compat.Verdict.OK, verdict.verdict, verdict.message ?: "")
|
||||||
|
|
||||||
|
// The profile stays reachable for a version the server would otherwise refuse: that is
|
||||||
|
// how a refused client discovers what it needs.
|
||||||
|
val ancient = clientAs("0.1.0")
|
||||||
|
val stillReadable = ancient.profile(cred)
|
||||||
|
assertEquals(profile.serverVersion, stillReadable.serverVersion,
|
||||||
|
"the profile endpoint must never be gated on app version")
|
||||||
|
|
||||||
|
// And a gated endpoint refuses it, with a message naming the window.
|
||||||
|
try {
|
||||||
|
ancient.createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
|
||||||
|
fail("server accepted a session from an out-of-window app")
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
val msg = assertNotNull(e.message)
|
||||||
|
println("refused as expected: $msg")
|
||||||
|
assertTrue(msg.contains("0.1.0"), "refusal should name the offending version: $msg")
|
||||||
|
assertTrue(msg.contains(profile.compat.appMin), "refusal should name the window: $msg")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Too new is refused the same way — the window is a range, not a floor.
|
||||||
|
try {
|
||||||
|
clientAs("99.0.0").createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
|
||||||
|
fail("server accepted a session from an app above its window")
|
||||||
|
} catch (e: VersionRefused) {
|
||||||
|
println("too-new refused as expected: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not
|
||||||
|
* just that packets moved: the tests must carry recomputable metrics and land on the right test
|
||||||
|
* types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*.
|
||||||
|
*/
|
||||||
|
class LiveDownstreamTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||||
|
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun producesDownstreamTestsAndFindings() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin))
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
val (tests, findings) = ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
ps.echo() // prime: the grant binds to the source the server has actually observed
|
||||||
|
DownstreamMeasurement(SystemIdSource())
|
||||||
|
.run(cred, session.sessionId, control, ps, sessionRef = "sess-1")
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
|
||||||
|
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
|
||||||
|
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
|
||||||
|
|
||||||
|
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train")
|
||||||
|
val byType = tests.associateBy { it.type }
|
||||||
|
|
||||||
|
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
|
||||||
|
assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL,
|
||||||
|
"DF probe did not deliver anything: ${pmtud.status}")
|
||||||
|
val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull()
|
||||||
|
assertNotNull(pathMtu, "pmtud_down must report a path MTU")
|
||||||
|
assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu")
|
||||||
|
println("downstream path MTU = $pathMtu bytes")
|
||||||
|
|
||||||
|
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
|
||||||
|
assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
|
||||||
|
|
||||||
|
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
|
||||||
|
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
|
||||||
|
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
|
||||||
|
assertTrue(received > 0, "no downstream train packets arrived")
|
||||||
|
println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " +
|
||||||
|
"reordered=${train.metrics?.get("reordered_packets")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.EnrollmentLink
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrolls against a LIVE server using the link the server itself minted (probe-protocol.md §2.1).
|
||||||
|
*
|
||||||
|
* This is the test that matters for enrollment, because the failure mode it guards against is a
|
||||||
|
* *disagreement* between two programs: the Go side assembles the link, the Kotlin side takes it
|
||||||
|
* apart, and if they differ by one percent-encoding the pin is wrong by one character — which
|
||||||
|
* does not fail loudly, it fails as an inscrutable TLS error days later. A unit test on either
|
||||||
|
* side alone cannot see that.
|
||||||
|
*
|
||||||
|
* Needs ECHOLOT_ENROLL_URI (minted over SSH by scripts/test-fmr.sh); self-skips without it.
|
||||||
|
*/
|
||||||
|
class LiveEnrollmentTest {
|
||||||
|
|
||||||
|
private val enrollUri = System.getenv("ECHOLOT_ENROLL_URI")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun enrollsFromTheServersOwnLink() {
|
||||||
|
if (enrollUri.isNullOrBlank()) {
|
||||||
|
println("LiveEnrollmentTest skipped (no ECHOLOT_ENROLL_URI)"); return
|
||||||
|
}
|
||||||
|
println("link: ${enrollUri.take(60)}…")
|
||||||
|
|
||||||
|
val link = assertNotNull(
|
||||||
|
EnrollmentLink.parse(enrollUri),
|
||||||
|
"the client could not parse a link the server produced — the two sides disagree",
|
||||||
|
)
|
||||||
|
println("parsed: url=${link.controlUrl} pin=${link.pin.take(12)}… token=${link.token.take(8)}…")
|
||||||
|
|
||||||
|
// Redeeming applies the pin to the very request that spends the token, so a wrong pin
|
||||||
|
// fails here at the handshake rather than after the token is gone.
|
||||||
|
val enrolled = link.redeem(deviceName = "live-test", appVersion = "0.2.0")
|
||||||
|
assertTrue(enrolled.credential.isNotBlank(), "no credential came back")
|
||||||
|
assertTrue(enrolled.deviceId.isNotBlank(), "no device id came back")
|
||||||
|
println("enrolled: device=${enrolled.deviceId} server=${enrolled.profile.name} " +
|
||||||
|
"${enrolled.profile.serverVersion}")
|
||||||
|
|
||||||
|
// The credential must actually work, and the pin from the link must be the one that
|
||||||
|
// verifies the server — that is the whole claim the link is making.
|
||||||
|
assertEquals(link.controlUrl, enrolled.controlUrl)
|
||||||
|
assertTrue(enrolled.profile.capabilities.contains("udp-probe"),
|
||||||
|
"profile fetched with the new credential looks wrong: ${enrolled.profile.capabilities}")
|
||||||
|
|
||||||
|
// Single-use: a token that still works after redemption is a token an attacker can reuse.
|
||||||
|
try {
|
||||||
|
link.redeem(deviceName = "should-not-happen", appVersion = "0.2.0")
|
||||||
|
fail("the enrollment token was accepted twice — it must be single-use")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
println("second redemption correctly refused: ${t.message?.take(120)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import app.echo_lot.protocol.Wire
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
|
||||||
|
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
|
||||||
|
*
|
||||||
|
* This is the direction a client cannot measure alone — only the far end can push large or
|
||||||
|
* numerous packets toward it — so it is also the direction that needs the anti-amplification
|
||||||
|
* grant, and this test is the proof that the grant path works end to end.
|
||||||
|
*/
|
||||||
|
class LiveGrantedTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||||
|
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downstreamTrainAndBigSend() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin))
|
||||||
|
val profile = control.profile(cred)
|
||||||
|
println("capabilities: ${profile.capabilities}")
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
|
||||||
|
val echo = ps.echo()
|
||||||
|
println("primed with echo rtt=${echo?.rttMs}")
|
||||||
|
|
||||||
|
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
|
||||||
|
val dtResp = control.action(
|
||||||
|
cred, session.sessionId,
|
||||||
|
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
|
||||||
|
)
|
||||||
|
println("downtrain accepted: ${dtResp.take(160)}")
|
||||||
|
val down = ps.collectGranted(windowMs = 4000)
|
||||||
|
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
|
||||||
|
val seqs = down.map { it.seq }.toSet()
|
||||||
|
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
|
||||||
|
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
||||||
|
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
|
||||||
|
|
||||||
|
// --- big_send with DF: the largest size that arrives is the downstream path MTU ---
|
||||||
|
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
|
||||||
|
val dfResp = control.action(
|
||||||
|
cred, session.sessionId,
|
||||||
|
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
|
||||||
|
)
|
||||||
|
println("big_send(df) accepted: ${dfResp.take(200)}")
|
||||||
|
val dfArrived = ps.collectGranted(windowMs = 4000)
|
||||||
|
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
|
||||||
|
println("big_send(df) arrived: $dfArrived")
|
||||||
|
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
|
||||||
|
val pathMtu = dfArrived.max()
|
||||||
|
|
||||||
|
// --- and without DF, to see whether fragments get through above that ---
|
||||||
|
val fragResp = control.action(
|
||||||
|
cred, session.sessionId,
|
||||||
|
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
|
||||||
|
)
|
||||||
|
println("big_send(frag) accepted: ${fragResp.take(200)}")
|
||||||
|
val fragArrived = ps.collectGranted(windowMs = 4000)
|
||||||
|
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
|
||||||
|
println("big_send(frag) arrived: $fragArrived")
|
||||||
|
|
||||||
|
// The distinction the DF flag exists for: fragmented delivery may exceed the
|
||||||
|
// unfragmented path MTU, and reporting the former as the latter would be a lie.
|
||||||
|
println("downstream path MTU (payload bytes) = $pathMtu; " +
|
||||||
|
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
|
||||||
|
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
|
||||||
|
"fragmented delivery should reach at least as far as unfragmented")
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the full server-facing engine against a live server and validates the produced
|
||||||
|
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
|
||||||
|
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
|
||||||
|
*/
|
||||||
|
class LiveMeasurementTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||||
|
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun producesValidDocumentFromLiveServer() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
val engine = ServerMeasurement(
|
||||||
|
ids = SystemIdSource(),
|
||||||
|
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
|
||||||
|
device = DeviceInfo("test", "jvm", 0, "n/a"),
|
||||||
|
)
|
||||||
|
val doc = engine.run(
|
||||||
|
ServerMeasurement.Config(
|
||||||
|
controlUrl = url, pins = setOf(pin), credential = cred,
|
||||||
|
target = target, udpHost = host, udpPort = port, echoCount = 20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The document must round-trip and carry the expected structure.
|
||||||
|
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
|
||||||
|
|
||||||
|
assertEquals(1, doc.serverSessions.size)
|
||||||
|
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
|
||||||
|
// A full run is the echo train plus the three downstream tests; assert on the one this
|
||||||
|
// test is about rather than on the count, so adding a measurement is not a test edit.
|
||||||
|
for (t in doc.tests) println(" ${t.type} → ${t.status}")
|
||||||
|
for (f in doc.findings) println(" finding ${f.code} [${f.severity}] ${f.title}")
|
||||||
|
val test = doc.tests.first { it.type == TestType.TRAIN_UDP_UPDOWN }
|
||||||
|
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
|
||||||
|
"expected replies from live server, got ${test.status}")
|
||||||
|
|
||||||
|
val metrics = Json.parseToJsonElement(test.metrics.toString())
|
||||||
|
println("metrics: $metrics")
|
||||||
|
assertTrue(metrics.toString().contains("rtt_ms_avg"))
|
||||||
|
|
||||||
|
assertTrue(doc.summary != null)
|
||||||
|
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
|
||||||
|
println("summary: ${doc.summary}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.privacy.Anonymizer
|
||||||
|
import app.echo_lot.privacy.PrivacyLevel
|
||||||
|
import app.echo_lot.privacy.Salt
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.UploadRefused
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
|
||||||
|
*
|
||||||
|
* The point is not that the HTTP works — it is that what comes *back off the server* has been
|
||||||
|
* stripped. Uploading and then re-reading the stored document is the only check that proves the
|
||||||
|
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
|
||||||
|
* ECHOLOT_LIVE_*.
|
||||||
|
*/
|
||||||
|
class LiveUploadTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val json = Json { prettyPrint = false }
|
||||||
|
|
||||||
|
private fun sampleRun(id: String) = """
|
||||||
|
{
|
||||||
|
"schema": "echolot/measurement",
|
||||||
|
"run": {
|
||||||
|
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
|
||||||
|
"notes": "kitchen table",
|
||||||
|
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
|
||||||
|
},
|
||||||
|
"networks": [{
|
||||||
|
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
|
||||||
|
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
|
||||||
|
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
|
||||||
|
}],
|
||||||
|
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
|
||||||
|
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
|
||||||
|
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
|
||||||
|
"summary": {"verdict": "warn"}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun uploadRoundTrip() {
|
||||||
|
if (url == null || pin == null || cred == null) {
|
||||||
|
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin))
|
||||||
|
val profile = control.profile(cred)
|
||||||
|
val policy = profile.uploads
|
||||||
|
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
|
||||||
|
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
|
||||||
|
|
||||||
|
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
|
||||||
|
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
|
||||||
|
val redacted = json.encodeToString(
|
||||||
|
JsonObject.serializer(),
|
||||||
|
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
|
||||||
|
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
|
||||||
|
)
|
||||||
|
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
|
||||||
|
|
||||||
|
if (!policy.accepted) {
|
||||||
|
// A server configured to refuse must refuse — that is the behaviour worth asserting.
|
||||||
|
try {
|
||||||
|
control.uploadRun(cred, redacted)
|
||||||
|
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
|
||||||
|
} catch (e: UploadRefused) {
|
||||||
|
println("upload correctly refused: ${e.message?.take(140)}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val created = control.uploadRun(cred, redacted)
|
||||||
|
println("stored: ${created.take(200)}")
|
||||||
|
|
||||||
|
val listed = control.listRuns(cred)
|
||||||
|
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
|
||||||
|
|
||||||
|
val fetched = control.getRun(cred, runId)
|
||||||
|
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
|
||||||
|
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
|
||||||
|
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
|
||||||
|
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
|
||||||
|
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
|
||||||
|
println("round trip verified: identifiers stripped, measurements intact")
|
||||||
|
|
||||||
|
control.deleteRun(cred, runId)
|
||||||
|
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
|
||||||
|
println("deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
|
||||||
|
// the Android app modules can depend on it and it stays unit-testable (incl.
|
||||||
|
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
|
||||||
|
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
// Build with the available JDK (Android Studio's JBR is 21) but emit
|
||||||
|
// Java-17 bytecode so the Android app modules can consume this library.
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package measurement models one measurement run (measurement-schema.md) — the archived,
|
||||||
|
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
|
||||||
|
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
|
||||||
|
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
|
||||||
|
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MeasurementDocument(
|
||||||
|
val schema: String = "echolot/measurement",
|
||||||
|
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
|
||||||
|
val run: Run,
|
||||||
|
val networks: List<Network> = emptyList(),
|
||||||
|
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
|
||||||
|
val tests: List<Test> = emptyList(),
|
||||||
|
val findings: List<Finding> = emptyList(),
|
||||||
|
val summary: Summary? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Run(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val trigger: Trigger,
|
||||||
|
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||||
|
@SerialName("ended_at") val endedAt: String? = null,
|
||||||
|
val clock: Clock,
|
||||||
|
val app: AppInfo,
|
||||||
|
val device: DeviceInfo,
|
||||||
|
val tiers: Tiers,
|
||||||
|
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Trigger {
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("scheduled") SCHEDULED,
|
||||||
|
@SerialName("monitor") MONITOR,
|
||||||
|
@SerialName("peer") PEER,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
|
||||||
|
* all math uses *_mono_ns relative to that monotonic origin. */
|
||||||
|
@Serializable
|
||||||
|
data class Clock(
|
||||||
|
@SerialName("mono_origin_wall") val monoOriginWall: String,
|
||||||
|
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
|
||||||
|
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppInfo(
|
||||||
|
val version: String,
|
||||||
|
val build: Int,
|
||||||
|
val git: String? = null,
|
||||||
|
val flavor: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceInfo(
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
@SerialName("android_sdk") val androidSdk: Int,
|
||||||
|
@SerialName("android_release") val androidRelease: String,
|
||||||
|
@SerialName("security_patch") val securityPatch: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What each tier was *available*; each test records what it *used*. */
|
||||||
|
@Serializable
|
||||||
|
data class Tiers(
|
||||||
|
val app: Boolean = true,
|
||||||
|
val shizuku: Boolean = false,
|
||||||
|
val root: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ServerSession(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("profile_id") val profileId: String? = null,
|
||||||
|
@SerialName("profile_name") val profileName: String? = null,
|
||||||
|
@SerialName("control_url") val controlUrl: String,
|
||||||
|
@SerialName("server_version") val serverVersion: String? = null,
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
val target: SessionTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionTarget(
|
||||||
|
val ip4: String? = null,
|
||||||
|
val ip6: String? = null,
|
||||||
|
@SerialName("udp_port") val udpPort: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
|
||||||
|
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
|
||||||
|
* means the columnar/traceroute/resolver contracts live in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
|
||||||
|
inline fun <reified T> T.toEvidence(): JsonObject =
|
||||||
|
evidenceJson.encodeToJsonElement(this) as JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
|
||||||
|
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
|
||||||
|
* columns use the server session epoch; only differences within one clock are meaningful unless a
|
||||||
|
* time.server_offset test maps them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TrainEvidence(
|
||||||
|
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
|
||||||
|
val seq: List<Int>,
|
||||||
|
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
|
||||||
|
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: List<Int>,
|
||||||
|
@SerialName("dscp_sent") val dscpSent: Int? = null,
|
||||||
|
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ecn_sent") val ecnSent: Int? = null,
|
||||||
|
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
|
||||||
|
@Serializable
|
||||||
|
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Flow(
|
||||||
|
@SerialName("src_port") val srcPort: Int,
|
||||||
|
@SerialName("dst_port") val dstPort: Int,
|
||||||
|
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Hop(val ttl: Int, val probes: List<HopProbe>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HopProbe(
|
||||||
|
@SerialName("reply_from") val replyFrom: String? = null,
|
||||||
|
@SerialName("rtt_ns") val rttNs: Long? = null,
|
||||||
|
val icmp: String? = null,
|
||||||
|
@SerialName("reply_ttl") val replyTtl: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Resolver under test (§6.4); every dns.* test carries this in params. */
|
||||||
|
@Serializable
|
||||||
|
data class ResolverSpec(
|
||||||
|
val source: ResolverSource,
|
||||||
|
val address: String? = null,
|
||||||
|
val port: Int = 53,
|
||||||
|
val transport: String, // do53-udp | do53-tcp | dot | doh
|
||||||
|
@SerialName("doh_url") val dohUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResolverSource {
|
||||||
|
@SerialName("system") SYSTEM,
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("server-recursive") SERVER_RECURSIVE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
|
||||||
|
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
|
||||||
|
@Serializable
|
||||||
|
data class Finding(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val code: String, // stable registry (findings-registry.md), lint-rule style
|
||||||
|
val category: Category,
|
||||||
|
val severity: Severity,
|
||||||
|
val confidence: Confidence,
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
|
||||||
|
val recommendation: String? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EvidenceRef(val test: String, val pointer: String? = null)
|
||||||
|
|
||||||
|
/** Fixed §7.2 categories; each maps to one traffic light. */
|
||||||
|
@Serializable
|
||||||
|
enum class Category {
|
||||||
|
@SerialName("connectivity") CONNECTIVITY,
|
||||||
|
@SerialName("dns") DNS,
|
||||||
|
@SerialName("nat") NAT,
|
||||||
|
@SerialName("mtu") MTU,
|
||||||
|
@SerialName("ipv6") IPV6,
|
||||||
|
@SerialName("security") SECURITY,
|
||||||
|
@SerialName("performance") PERFORMANCE,
|
||||||
|
@SerialName("local") LOCAL,
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
|
||||||
|
@Serializable
|
||||||
|
enum class Severity(val rank: Int) {
|
||||||
|
@SerialName("critical") CRITICAL(4),
|
||||||
|
@SerialName("high") HIGH(3),
|
||||||
|
@SerialName("medium") MEDIUM(2),
|
||||||
|
@SerialName("low") LOW(1),
|
||||||
|
@SerialName("info") INFO(0);
|
||||||
|
|
||||||
|
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
|
||||||
|
fun toLight(): Verdict = when (this) {
|
||||||
|
CRITICAL, HIGH -> Verdict.RED
|
||||||
|
MEDIUM, LOW -> Verdict.YELLOW
|
||||||
|
INFO -> Verdict.GREEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Confidence {
|
||||||
|
@SerialName("high") HIGH,
|
||||||
|
@SerialName("medium") MEDIUM,
|
||||||
|
@SerialName("low") LOW,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
|
||||||
|
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
|
||||||
|
@Serializable
|
||||||
|
data class Network(
|
||||||
|
val id: String,
|
||||||
|
val transport: Transport,
|
||||||
|
@SerialName("interface") val iface: String? = null,
|
||||||
|
val link: Link,
|
||||||
|
val wifi: Wifi? = null,
|
||||||
|
val cellular: Cellular? = null,
|
||||||
|
val changes: List<NetworkChange> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Transport {
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
@SerialName("cellular") CELLULAR,
|
||||||
|
@SerialName("ethernet") ETHERNET,
|
||||||
|
@SerialName("vpn") VPN,
|
||||||
|
@SerialName("other") OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Link(
|
||||||
|
val mtu: Int? = null,
|
||||||
|
val addresses: List<Address> = emptyList(),
|
||||||
|
val routes: List<Route> = emptyList(),
|
||||||
|
val dns: DnsConfig? = null,
|
||||||
|
val dhcp: Dhcp? = null,
|
||||||
|
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Address(
|
||||||
|
val addr: String, // ip4 | ip6 (logical type, §8)
|
||||||
|
@SerialName("prefix_len") val prefixLen: Int,
|
||||||
|
val scope: String? = null,
|
||||||
|
val flags: List<String> = emptyList(),
|
||||||
|
@SerialName("valid_lft_s") val validLftS: Long? = null,
|
||||||
|
@SerialName("pref_lft_s") val prefLftS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Route(
|
||||||
|
val dst: String,
|
||||||
|
val gateway: String? = null,
|
||||||
|
val iface: String? = null,
|
||||||
|
val proto: RouteProto? = null, // shizuku tier; null = not observed
|
||||||
|
@SerialName("expires_s") val expiresS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RouteProto {
|
||||||
|
@SerialName("dhcp") DHCP,
|
||||||
|
@SerialName("ra") RA,
|
||||||
|
@SerialName("static") STATIC,
|
||||||
|
@SerialName("unknown") UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DnsConfig(
|
||||||
|
val servers: List<String> = emptyList(),
|
||||||
|
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
|
||||||
|
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
|
||||||
|
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
|
||||||
|
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CaptivePortal(
|
||||||
|
val detected: Boolean = false,
|
||||||
|
@SerialName("api_url") val apiUrl: String? = null,
|
||||||
|
@SerialName("venue_url") val venueUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Wifi(
|
||||||
|
val ssid: String? = null, // ssid (logical type)
|
||||||
|
val bssid: String? = null, // bssid (logical type)
|
||||||
|
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
|
||||||
|
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
|
||||||
|
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
|
||||||
|
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
|
||||||
|
val standard: String? = null,
|
||||||
|
val security: String? = null,
|
||||||
|
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Cellular(
|
||||||
|
val rat: String? = null,
|
||||||
|
val operator: String? = null,
|
||||||
|
val band: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkChange(
|
||||||
|
@SerialName("at_mono_ns") val atMonoNs: Long,
|
||||||
|
val kind: String, // lost | gained | link_changed
|
||||||
|
val detail: JsonObject? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Verdict {
|
||||||
|
@SerialName("green") GREEN,
|
||||||
|
@SerialName("yellow") YELLOW,
|
||||||
|
@SerialName("red") RED,
|
||||||
|
@SerialName("inconclusive") INCONCLUSIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Summary(
|
||||||
|
val overall: Verdict,
|
||||||
|
val categories: Map<String, CategorySummary>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CategorySummary(
|
||||||
|
val verdict: Verdict,
|
||||||
|
@SerialName("worst_finding") val worstFinding: String? = null,
|
||||||
|
@SerialName("tests_run") val testsRun: Int,
|
||||||
|
@SerialName("tests_failed") val testsFailed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
|
||||||
|
*
|
||||||
|
* - A category's verdict = the light of its worst-severity finding
|
||||||
|
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||||
|
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||||
|
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
||||||
|
*
|
||||||
|
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||||
|
* findings or tests appear in the summary.
|
||||||
|
*/
|
||||||
|
object Verdicts {
|
||||||
|
|
||||||
|
private fun isInconclusiveTest(s: TestStatus) =
|
||||||
|
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||||
|
|
||||||
|
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
||||||
|
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||||
|
val findingsByCat = findings.groupBy { it.category }
|
||||||
|
val categories = (testsByCat.keys + findingsByCat.keys)
|
||||||
|
|
||||||
|
val perCat = LinkedHashMap<String, CategorySummary>()
|
||||||
|
for (cat in Category.entries) {
|
||||||
|
if (cat !in categories) continue
|
||||||
|
val catTests = testsByCat[cat].orEmpty()
|
||||||
|
val catFindings = findingsByCat[cat].orEmpty()
|
||||||
|
|
||||||
|
val failed = catTests.count { isInconclusiveTest(it.status) }
|
||||||
|
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
|
||||||
|
|
||||||
|
val worst = catFindings.maxByOrNull { it.severity.rank }
|
||||||
|
val verdict = when {
|
||||||
|
inconclusive -> Verdict.INCONCLUSIVE
|
||||||
|
worst == null -> Verdict.GREEN
|
||||||
|
else -> worst.severity.toLight()
|
||||||
|
}
|
||||||
|
perCat[serialName(cat)] = CategorySummary(
|
||||||
|
verdict = verdict,
|
||||||
|
worstFinding = worst?.id,
|
||||||
|
testsRun = catTests.size,
|
||||||
|
testsFailed = failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val overall = deriveOverall(perCat.values)
|
||||||
|
return Summary(overall = overall, categories = perCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall = worst light; inconclusive only if every category is inconclusive. */
|
||||||
|
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
|
||||||
|
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
|
||||||
|
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
|
||||||
|
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
|
||||||
|
// Non-inconclusive categories decide the overall light.
|
||||||
|
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
|
||||||
|
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serialName(cat: Category): String = cat.name.lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
|
||||||
|
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
|
||||||
|
* per-test-type JSON, so they are carried as JsonObject. */
|
||||||
|
@Serializable
|
||||||
|
data class Test(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val type: String, // TestType registry (§6.1)
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
|
||||||
|
val tier: Tier,
|
||||||
|
@SerialName("started_mono_ns") val startedMonoNs: Long,
|
||||||
|
@SerialName("ended_mono_ns") val endedMonoNs: Long,
|
||||||
|
val status: TestStatus,
|
||||||
|
val error: TestError? = null,
|
||||||
|
val params: JsonObject? = null,
|
||||||
|
val evidence: JsonObject? = null,
|
||||||
|
val metrics: JsonObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Tier {
|
||||||
|
@SerialName("app") APP,
|
||||||
|
@SerialName("shizuku") SHIZUKU,
|
||||||
|
@SerialName("root") ROOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class TestStatus {
|
||||||
|
@SerialName("ok") OK,
|
||||||
|
@SerialName("failed") FAILED,
|
||||||
|
@SerialName("unsupported") UNSUPPORTED,
|
||||||
|
@SerialName("skipped") SKIPPED,
|
||||||
|
@SerialName("partial") PARTIAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestError(val code: String, val detail: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
|
||||||
|
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
|
||||||
|
* the fixed §7.2 categories for verdict rollup.
|
||||||
|
*/
|
||||||
|
object TestType {
|
||||||
|
// link
|
||||||
|
const val LINK_SNAPSHOT = "link.snapshot"
|
||||||
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
|
const val LINK_IP_MONITOR = "link.ip_monitor"
|
||||||
|
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
|
||||||
|
const val LINK_RA_SOURCE = "link.ra_source"
|
||||||
|
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||||
|
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||||
|
// icmp
|
||||||
|
const val ICMP_PING4 = "icmp.ping4"
|
||||||
|
const val ICMP_PING6 = "icmp.ping6"
|
||||||
|
// trace
|
||||||
|
const val TRACEROUTE_UDP4 = "traceroute.udp4"
|
||||||
|
const val TRACEROUTE_UDP6 = "traceroute.udp6"
|
||||||
|
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
|
||||||
|
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
|
||||||
|
// train
|
||||||
|
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
|
||||||
|
/** Server-to-client train under a §3.4 grant: the direction a round trip cannot separate. */
|
||||||
|
const val TRAIN_UDP_DOWNSTREAM = "train.udp_downstream"
|
||||||
|
// mtu
|
||||||
|
const val MTU_PMTUD_UP = "mtu.pmtud_up"
|
||||||
|
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
|
||||||
|
const val MTU_BLACKHOLE = "mtu.blackhole"
|
||||||
|
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
|
||||||
|
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
|
||||||
|
// nat
|
||||||
|
const val NAT_STUN_5780 = "nat.stun_5780"
|
||||||
|
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
|
||||||
|
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
|
||||||
|
const val NAT_HAIRPIN = "nat.hairpin"
|
||||||
|
const val NAT_CONNECT_BACK = "nat.connect_back"
|
||||||
|
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
|
||||||
|
// dns
|
||||||
|
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||||
|
const val DNS_CANARY = "dns.canary"
|
||||||
|
const val DNS_INTERCEPTION = "dns.interception"
|
||||||
|
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||||
|
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||||
|
const val DNS_DNSSEC = "dns.dnssec"
|
||||||
|
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
|
||||||
|
const val DNS_REBIND_FILTER = "dns.rebind_filter"
|
||||||
|
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
|
||||||
|
const val DNS_DNS64 = "dns.dns64"
|
||||||
|
const val DNS_COMPARE = "dns.compare"
|
||||||
|
// sec
|
||||||
|
const val SEC_TLS_REFERENCE = "sec.tls_reference"
|
||||||
|
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
|
||||||
|
const val SEC_HTTP_ECHO = "sec.http_echo"
|
||||||
|
const val SEC_SNI_FILTER = "sec.sni_filter"
|
||||||
|
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
|
||||||
|
const val SEC_ARP_WATCH = "sec.arp_watch"
|
||||||
|
// port
|
||||||
|
const val PORT_REACH_SWEEP = "port.reach_sweep"
|
||||||
|
const val PORT_UDP_USABILITY = "port.udp_usability"
|
||||||
|
// perf
|
||||||
|
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
|
||||||
|
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
|
||||||
|
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
|
||||||
|
const val PERF_RRC_LATENCY = "perf.rrc_latency"
|
||||||
|
// v6
|
||||||
|
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
|
||||||
|
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
|
||||||
|
const val V6_BROKENNESS = "v6.brokenness"
|
||||||
|
const val V6_NAT64_CLAT = "v6.nat64_clat"
|
||||||
|
// wifi
|
||||||
|
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
|
||||||
|
const val WIFI_ROAM_LOG = "wifi.roam_log"
|
||||||
|
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
|
||||||
|
// local
|
||||||
|
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||||
|
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||||
|
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||||
|
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||||
|
const val LOCAL_NTP = "local.ntp"
|
||||||
|
// peer
|
||||||
|
const val PEER_REACHABILITY = "peer.reachability"
|
||||||
|
const val PEER_ISOLATION = "peer.isolation"
|
||||||
|
const val PEER_MULTICAST = "peer.multicast"
|
||||||
|
const val PEER_LAN_TRAIN = "peer.lan_train"
|
||||||
|
const val PEER_LEASE_DIFF = "peer.lease_diff"
|
||||||
|
// time
|
||||||
|
const val TIME_SERVER_OFFSET = "time.server_offset"
|
||||||
|
|
||||||
|
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
|
||||||
|
fun category(type: String): Category = when (type.substringBefore('.')) {
|
||||||
|
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
|
||||||
|
"dns" -> Category.DNS
|
||||||
|
"nat" -> Category.NAT
|
||||||
|
"mtu" -> Category.MTU
|
||||||
|
"v6" -> Category.IPV6
|
||||||
|
"sec" -> Category.SECURITY
|
||||||
|
"perf" -> Category.PERFORMANCE
|
||||||
|
"local", "peer" -> Category.LOCAL
|
||||||
|
"wifi" -> Category.WIFI
|
||||||
|
else -> Category.CONNECTIVITY
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SerializationTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun documentRoundTrips() {
|
||||||
|
val doc = MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = "0198c5f2-0000-7000-8000-000000000000",
|
||||||
|
trigger = Trigger.MANUAL,
|
||||||
|
startedAt = "2026-07-31T14:03:21.114Z",
|
||||||
|
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
|
||||||
|
app = AppInfo(version = "0.1.0", build = 1),
|
||||||
|
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
|
||||||
|
tiers = Tiers(app = true, shizuku = true),
|
||||||
|
),
|
||||||
|
networks = listOf(
|
||||||
|
Network(
|
||||||
|
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
|
||||||
|
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
|
||||||
|
wifi = Wifi(ssid = "example", rssiDbm = -54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tests = listOf(
|
||||||
|
Test(
|
||||||
|
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
|
||||||
|
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
|
||||||
|
evidence = TrainEvidence(
|
||||||
|
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
|
||||||
|
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
|
||||||
|
).toEvidence(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
|
||||||
|
assertEquals(doc.run.id, decoded.run.id)
|
||||||
|
assertEquals(Transport.WIFI, decoded.networks[0].transport)
|
||||||
|
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
|
||||||
|
// snake_case field names on the wire
|
||||||
|
assertTrue(encoded.contains("\"schema_version\""))
|
||||||
|
assertTrue(encoded.contains("\"mono_origin_wall\""))
|
||||||
|
assertTrue(encoded.contains("\"t_tx_ns\""))
|
||||||
|
// null preserved at train index 1
|
||||||
|
assertTrue(encoded.contains("[16500000,null]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun findingRequiresEvidence() {
|
||||||
|
try {
|
||||||
|
Finding(
|
||||||
|
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
|
||||||
|
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
|
||||||
|
)
|
||||||
|
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// expected — a finding with no evidence is invalid (§7.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class VerdictsTest {
|
||||||
|
|
||||||
|
private fun test(type: String, status: TestStatus, id: String = type): Test =
|
||||||
|
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
|
||||||
|
|
||||||
|
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
|
||||||
|
Finding(
|
||||||
|
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryLightFromWorstSeverity() {
|
||||||
|
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.LOW),
|
||||||
|
finding(Category.DNS, Severity.HIGH), // worst → red
|
||||||
|
finding(Category.DNS, Severity.INFO),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun noFindingsIsGreen() {
|
||||||
|
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun mediumAndLowAreYellow() {
|
||||||
|
val s = Verdicts.derive(
|
||||||
|
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
|
||||||
|
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
|
||||||
|
)
|
||||||
|
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun majorityFailedIsInconclusive() {
|
||||||
|
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
|
||||||
|
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(2, s.categories["dns"]!!.testsFailed)
|
||||||
|
assertEquals(3, s.categories["dns"]!!.testsRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun exactlyHalfFailedIsNotInconclusive() {
|
||||||
|
// 1 of 2 failed → not > 50% → the finding decides.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
|
||||||
|
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallIsWorstCategory() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
|
||||||
|
)
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.MEDIUM), // yellow
|
||||||
|
finding(Category.MTU, Severity.CRITICAL), // red
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallInconclusiveOnlyWhenAllAre() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, emptyList())
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryMappingCoversFamilies() {
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
|
||||||
|
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
|
||||||
|
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
|
||||||
|
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
|
||||||
|
// dependency-free beyond JSON: it must be trivially auditable, because a bug
|
||||||
|
// here leaks a user's network onto someone else's server.
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
|
tasks.test { useJUnitPlatform() }
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package privacy implements the anonymization contract of measurement-schema.md §8.
|
||||||
|
//
|
||||||
|
// The threat model is specific. An engineer running their own server wants the full document —
|
||||||
|
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
|
||||||
|
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
|
||||||
|
// filter: the output is still a valid measurement document with the same tests, metrics and
|
||||||
|
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
|
||||||
|
// still answerable from pseudonyms alone.
|
||||||
|
//
|
||||||
|
// Two properties are load-bearing and are what the tests pin:
|
||||||
|
// - Consistency within a document: one input value always maps to one pseudonym, so
|
||||||
|
// correlations inside a run survive.
|
||||||
|
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
|
||||||
|
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
|
||||||
|
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
|
||||||
|
package app.echo_lot.privacy
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
|
||||||
|
enum class PrivacyLevel(val wire: String) {
|
||||||
|
/** Nothing removed. The right choice for your own server. */
|
||||||
|
FULL("full"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
|
||||||
|
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
|
||||||
|
* which MikroTik, on which SSID, next to whose Chromecast.
|
||||||
|
*/
|
||||||
|
BALANCED("balanced"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
|
||||||
|
* codes and severities but lose descriptions (which quote real names). What is left cannot
|
||||||
|
* identify a network, and is still enough for aggregate "how common is this fault" work.
|
||||||
|
*/
|
||||||
|
STRICT("strict");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(s: String?): PrivacyLevel =
|
||||||
|
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
|
||||||
|
|
||||||
|
/** The stricter of two levels — used to honour a server's minimum. */
|
||||||
|
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
|
||||||
|
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
|
||||||
|
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
|
||||||
|
* own — the app makes that an explicit choice, not a default.
|
||||||
|
*/
|
||||||
|
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
|
||||||
|
companion object {
|
||||||
|
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
|
||||||
|
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a measurement document to [level].
|
||||||
|
*
|
||||||
|
* Field classification is by JSON key name, because the schema names things consistently
|
||||||
|
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
|
||||||
|
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
|
||||||
|
* a known-sensitive container, and left alone otherwise — see [Classification].
|
||||||
|
*/
|
||||||
|
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
||||||
|
|
||||||
|
private val cache = HashMap<String, String>()
|
||||||
|
|
||||||
|
fun anonymize(doc: JsonObject): JsonObject {
|
||||||
|
if (level == PrivacyLevel.FULL) return stamp(doc)
|
||||||
|
val walked = walkObject(doc, path = emptyList())
|
||||||
|
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
|
||||||
|
return stamp(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
|
||||||
|
private fun stamp(doc: JsonObject): JsonObject {
|
||||||
|
val run = doc["run"]?.jsonObject ?: return doc
|
||||||
|
val privacy = buildJsonObject {
|
||||||
|
put("anonymization", level.wire)
|
||||||
|
put("salt", if (salt.stable) "stable" else "per_run")
|
||||||
|
}
|
||||||
|
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the tree walk -------------------------------------------------------------------
|
||||||
|
|
||||||
|
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
|
||||||
|
for ((k, v) in obj) {
|
||||||
|
val childPath = path + k
|
||||||
|
when {
|
||||||
|
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
|
||||||
|
else -> put(k, walk(k, v, childPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
|
||||||
|
is JsonObject -> walkObject(v, path)
|
||||||
|
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
|
||||||
|
is JsonPrimitive ->
|
||||||
|
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
|
||||||
|
else v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||||
|
null -> value
|
||||||
|
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
|
||||||
|
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
||||||
|
LogicalType.IP4 -> ip4(value)
|
||||||
|
LogicalType.IP6 -> ip6(value)
|
||||||
|
LogicalType.FQDN -> fqdn(value)
|
||||||
|
LogicalType.OPAQUE_ID -> "redacted"
|
||||||
|
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- per-type transforms -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
|
||||||
|
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
|
||||||
|
* MikroTik" does not.
|
||||||
|
*/
|
||||||
|
private fun macPreservingOui(value: String): String {
|
||||||
|
val sep = if (value.contains('-')) '-' else ':'
|
||||||
|
val parts = value.split(sep)
|
||||||
|
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
|
||||||
|
val nic = pseudo("mac", value) { it }
|
||||||
|
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
|
||||||
|
.joinToString(sep.toString())
|
||||||
|
.lowercase(Locale.ROOT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
|
||||||
|
* CGNAT addresses say something about the topology and nothing about the person, and a run
|
||||||
|
* where 192.168.1.1 became a random public address would be actively misleading to read.
|
||||||
|
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
|
||||||
|
*/
|
||||||
|
private fun ip4(value: String): String {
|
||||||
|
val o = value.split(".")
|
||||||
|
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
|
||||||
|
val n = o.map { it.toInt() }
|
||||||
|
val reserved = n[0] == 10 ||
|
||||||
|
(n[0] == 172 && n[1] in 16..31) ||
|
||||||
|
(n[0] == 192 && n[1] == 168) ||
|
||||||
|
(n[0] == 169 && n[1] == 254) ||
|
||||||
|
(n[0] == 100 && n[1] in 64..127) ||
|
||||||
|
n[0] == 127 || n[0] == 0 || n[0] >= 224
|
||||||
|
if (reserved) return value
|
||||||
|
val h = pseudo("ip4", value) { it }
|
||||||
|
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
|
||||||
|
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
|
||||||
|
* is a device fingerprint, especially with EUI-64.
|
||||||
|
*/
|
||||||
|
private fun ip6(value: String): String {
|
||||||
|
val v = value.lowercase(Locale.ROOT)
|
||||||
|
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
|
||||||
|
val groups = v.substringBefore('%').split(":")
|
||||||
|
if (groups.size < 3) return v
|
||||||
|
val h = pseudo("ip6", value) { it }
|
||||||
|
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
|
||||||
|
* or "…under example.com" survives without naming the host. The suffix list is deliberately
|
||||||
|
* short: guessing wrong keeps *more* pseudonymized, never less.
|
||||||
|
*/
|
||||||
|
private fun fqdn(value: String): String {
|
||||||
|
if (value.isEmpty()) return value
|
||||||
|
val trailing = value.endsWith(".")
|
||||||
|
val labels = value.trimEnd('.').split(".")
|
||||||
|
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
|
||||||
|
val keep = if (labels.last() in publicSuffixes) 1 else 0
|
||||||
|
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
|
||||||
|
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- STRICT ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
|
||||||
|
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
|
||||||
|
* (it interpolates real names), networks go entirely.
|
||||||
|
*/
|
||||||
|
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
|
||||||
|
for ((k, v) in doc) {
|
||||||
|
when (k) {
|
||||||
|
"networks", "server_sessions" -> Unit
|
||||||
|
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
|
||||||
|
val o = t.jsonObject
|
||||||
|
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
|
||||||
|
}))
|
||||||
|
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
|
||||||
|
val o = f.jsonObject
|
||||||
|
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
|
||||||
|
}))
|
||||||
|
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
|
||||||
|
else -> put(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- pseudonym machinery ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
||||||
|
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
||||||
|
cache.getOrPut("$domain | ||||||