Compare commits
@@ -10,12 +10,18 @@
|
|||||||
# releases don't trigger each other's pipelines.
|
# releases don't trigger each other's pipelines.
|
||||||
#
|
#
|
||||||
# Required secrets:
|
# Required secrets:
|
||||||
# REGISTRY_TOKEN personal access token with read+write package scope —
|
# REGISTRY_TOKEN personal access token with read+write package scope —
|
||||||
# the built-in Actions token is NOT accepted by the
|
# the built-in Actions token is NOT accepted by the
|
||||||
# container registry (docker login → unauthorized).
|
# container registry (docker login → unauthorized).
|
||||||
# Create: user Settings → Applications → Generate token.
|
# Create: user Settings → Applications → Generate token.
|
||||||
# REGISTRY_USER optional; defaults to the pushing actor's username.
|
# REGISTRY_USER optional; defaults to the pushing actor's username.
|
||||||
# The release job needs only the built-in GITHUB_TOKEN.
|
# RELEASE_SIGNING_KEY base64 ed25519 seed that signs SHA256SUMS. Self-updating
|
||||||
|
# servers verify the signature against the public key baked
|
||||||
|
# into the binary (selfupdate.DefaultPublicKeyB64) and REFUSE
|
||||||
|
# unsigned releases, so this job hard-fails without it —
|
||||||
|
# a release nobody can install is better failed loudly here.
|
||||||
|
# Mint a pair with: go run ./cmd/release-sign -gen
|
||||||
|
# The release job otherwise needs only the built-in GITHUB_TOKEN.
|
||||||
|
|
||||||
name: server-release
|
name: server-release
|
||||||
on:
|
on:
|
||||||
@@ -44,6 +50,18 @@ jobs:
|
|||||||
done
|
done
|
||||||
(cd ../dist && sha256sum * > SHA256SUMS)
|
(cd ../dist && sha256sum * > SHA256SUMS)
|
||||||
|
|
||||||
|
- name: Sign SHA256SUMS
|
||||||
|
working-directory: server
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
[ -n "$RELEASE_SIGNING_KEY" ] || { echo "::error::secret RELEASE_SIGNING_KEY is missing — self-updating servers refuse unsigned releases, so publishing one would strand the fleet. Add it under Settings → Actions → Secrets."; exit 1; }
|
||||||
|
go run ./cmd/release-sign ../dist/SHA256SUMS
|
||||||
|
# Verify with the key baked into the binary we just built — catches a
|
||||||
|
# secret that does not match DefaultPublicKeyB64 before it ships.
|
||||||
|
PUB=$(grep -o 'DefaultPublicKeyB64 = "[^"]*"' internal/selfupdate/selfupdate.go | cut -d'"' -f2)
|
||||||
|
go run ./cmd/release-sign -verify -pub "$PUB" ../dist/SHA256SUMS
|
||||||
|
|
||||||
- name: Create release + attach binaries
|
- name: Create release + attach binaries
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -40,3 +40,9 @@ 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/
|
||||||
|
|
||||||
|
# Kotlin compiler scratch/error logs
|
||||||
|
echolot-app/.kotlin/
|
||||||
|
|||||||
@@ -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,47 @@ 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).
|
||||||
|
- **A poisoned Gradle *build cache* entry can silently drop a whole module from the APK.**
|
||||||
|
Symptom: the app dies with `ClassNotFoundException` for a class that plainly exists, while the
|
||||||
|
build is green and `./gradlew :app:dependencies` lists the module on `debugRuntimeClasspath`.
|
||||||
|
The module's own jar is correct; its code simply never reaches AGP's intermediates. `clean`,
|
||||||
|
`rm -rf */build` and `--rerun-tasks` all fail to fix it, because **none of them touch the build
|
||||||
|
cache** — look for `compileKotlin FROM-CACHE` in the log. Fix: rebuild with `--no-build-cache`.
|
||||||
|
Verify by grepping the APK's dex for a string literal that only that module defines; grepping for
|
||||||
|
a *class name* proves nothing, because callers carry the name as a reference whether or not the
|
||||||
|
class is packaged:
|
||||||
|
`unzip -o -q app-debug.apk "classes*.dex" && grep -a "pin-sha256:" *.dex`
|
||||||
|
Suspect this whenever a runtime failure contradicts a successful build.
|
||||||
|
- **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`.
|
||||||
@@ -94,3 +173,11 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
|
|||||||
are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
|
are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
|
||||||
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
|
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
|
||||||
`core-probe` / `core-shizuku` modules.
|
`core-probe` / `core-shizuku` modules.
|
||||||
|
|
||||||
|
## Enrolling a device with a server
|
||||||
|
|
||||||
|
`echolot-app/scripts/enroll-link.sh [note]` mints a §2.1 bootstrap link on fmr over SSH and prints
|
||||||
|
it (plus a QR if `qrencode` is installed, plus the `adb shell am start -a …VIEW -d '<uri>'` command
|
||||||
|
when a device is attached). The link carries a single-use token — treat it as a secret until spent.
|
||||||
|
Never hand-assemble one: the base64 pin needs percent-encoding, and a pin wrong by one character
|
||||||
|
fails as an inscrutable TLS error rather than as a bad pin.
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: CC-BY-4.0
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Echolot findings registry
|
||||||
|
|
||||||
|
Closes open item 1 of `measurement-schema.md` §9.
|
||||||
|
|
||||||
|
A **finding code** is the stable, machine-readable half of a result. The prose around it changes
|
||||||
|
freely; the code is what a dashboard groups by, what a diff between two runs keys on, and what
|
||||||
|
someone greps a year of archived runs for. That only works if a code means exactly one thing,
|
||||||
|
forever.
|
||||||
|
|
||||||
|
This document is the contract. It is kept in step with
|
||||||
|
`echolot-app/core-measurement/.../FindingRegistry.kt` by a test that fails when either side has a
|
||||||
|
code the other does not — a registry that drifts from its documentation is worse than none,
|
||||||
|
because it looks authoritative.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **The prefix determines the category**, and the category determines which verdict light the
|
||||||
|
finding rolls up into (§7.3). A `nat.*` code appearing under *connectivity* is not a naming
|
||||||
|
quibble; it changes which light turns red. Two codes were renamed from `nat.*` to
|
||||||
|
`connectivity.*` for exactly this reason.
|
||||||
|
2. **One code per concept.** Two emitters independently produced `connectivity.downstream_loss`
|
||||||
|
and `connectivity.loss_downstream` for the same claim before this registry existed. Anyone
|
||||||
|
aggregating either would have silently seen half their data.
|
||||||
|
3. **Codes are declared, not typed.** Emitters reference a `FindingSpec`, so a typo is a compile
|
||||||
|
error and no two call sites can disagree about a finding's category or default severity.
|
||||||
|
4. **Severity in the registry is the default.** An emitter may escalate for a specific run; it may
|
||||||
|
not quietly reclassify the finding in general.
|
||||||
|
5. **Say what is ruled out**, where that is the useful half. "Loss upstream" is worth far more
|
||||||
|
when it also states that the return path is clean, because that halves where to look next.
|
||||||
|
6. **Renaming a code is a breaking change** once runs are archived at scale. Before 1.0 it is
|
||||||
|
cheap; after, it needs an alias and a deprecation window.
|
||||||
|
|
||||||
|
## Registry
|
||||||
|
|
||||||
|
### connectivity
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `connectivity.udp_unreachable` | high | No UDP echo replies came back from the server at all. | — |
|
||||||
|
| `connectivity.udp_unreachable_upstream` | high | The server received none of the probes, so traffic is dropped on the way out. | The return path: nothing arrived to be replied to. |
|
||||||
|
| `connectivity.udp_loss` | medium | A large fraction of round-trip probes were lost, direction unknown. | — |
|
||||||
|
| `connectivity.loss_upstream` | medium | Probes were lost on the way to the server. | The return path: replies came back for everything that arrived. |
|
||||||
|
| `connectivity.loss_downstream` | medium | Packets were lost on the way back from the server. | The outbound path: the server received what it was answering. |
|
||||||
|
| `connectivity.downstream_blocked` | high | Server-initiated packets never arrive, although round trips work. | Basic reachability: the path forwards replies, just not unsolicited traffic. |
|
||||||
|
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
|
||||||
|
| `connectivity.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
|
||||||
|
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
||||||
|
|
||||||
|
### mtu
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `mtu.reduced_downstream` | low | The downstream path MTU is below the usual 1500 bytes. | — |
|
||||||
|
| `mtu.downstream_blackhole` | medium | Datagrams above the path MTU are dropped downstream, fragmented or not. | — |
|
||||||
|
| `mtu.fragments_blocked` | medium | IP fragments do not reach this device even when sent in order. | — |
|
||||||
|
| `mtu.fragment_reorder_sensitive` | low | Fragments are delivered in order but dropped when reordered or delayed. | Fragmentation itself: in-order fragments arrive fine. |
|
||||||
|
|
||||||
|
### nat
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nat.udp_rebinding` | medium | A NAT remapped the UDP source port mid-flow. | — |
|
||||||
|
| `nat.symmetric` | medium | The NAT assigns a different external port per destination. | — |
|
||||||
|
|
||||||
|
### perf
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `perf.throughput_no_delivery` | high | No throughput traffic arrived, although the server sent it. | — |
|
||||||
|
| `perf.throughput_below_offered` | low | Less throughput arrived than the server sent for the whole run. | — |
|
||||||
|
|
||||||
|
### dns
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dns.answer_rewritten` | high | A resolver returned an answer that differs from the authoritative record. | — |
|
||||||
|
| `dns.authoritative_unreachable` | medium | The canary zone's authoritative server could not be reached. | — |
|
||||||
|
|
||||||
|
### v6
|
||||||
|
|
||||||
|
The prefix is `v6.`, matching the test-type registry (`v6.brokenness`, `v6.happy_eyeballs`, …).
|
||||||
|
These were `ipv6.*` while declaring `Category.IPV6`; since the prefix map only knows `v6`, they
|
||||||
|
rolled up under *connectivity* instead — the third occurrence of rule 1 being broken.
|
||||||
|
|
||||||
|
| code | severity | means | rules out |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `dns.search_domain_unanswered` | high | The network advertises a DNS search domain that its own server does not answer for. | A fault on this device: the same server answers ordinary names normally. |
|
||||||
|
| `dns.system_resolver_broken` | high | The network's DNS server answers, but this device cannot resolve names through it. | A network fault: the server replied to a query sent from this device. |
|
||||||
|
| `measurement.vpn_constrained` | info | A VPN was active, so the networks underneath it could not be measured. | Nothing — this run says little about the underlying network either way. |
|
||||||
|
| `v6.no_default_route` | medium | The device has a global IPv6 address but no IPv6 default route. | Guesswork: this is read from the routing table, not inferred from silence. |
|
||||||
|
| `v6.route_without_address` | medium | The network advertises an IPv6 default route but the device has no global IPv6 address. | A working IPv6 setup: SLAAC did not produce a usable address on this link. |
|
||||||
|
| `v6.no_icmp_reply` | low | IPv6 is configured but ICMPv6 echo gets no reply. | Nothing on its own: IPv6 may work fine with ICMP filtered. |
|
||||||
|
| `v6.broken` | high | IPv6 is advertised on this network but carries no traffic. | ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too. |
|
||||||
|
| `v6.not_offered` | info | This network does not offer IPv6. | — |
|
||||||
|
|
||||||
|
`v6.no_icmp_reply` was `v6.broken` until a phone reported it while loading an IPv6-only site over
|
||||||
|
TCP perfectly well. The only evidence behind it is ICMPv6 echo, which is widely filtered on
|
||||||
|
networks where IPv6 works — so the finding now states what was observed and names both
|
||||||
|
explanations instead of choosing one. It is still worth reporting: filtered ICMPv6 breaks Path MTU
|
||||||
|
Discovery.
|
||||||
|
|
||||||
|
`v6.broken` returned once that corroboration existed: the `v6.brokenness` test attempts a real TCP
|
||||||
|
connection over IPv6 to the configured server, and only when *both* transports fail on a network
|
||||||
|
that advertises IPv6 is the brokenness claim made — at high severity, because every dual-stack
|
||||||
|
destination pays a timeout before falling back to IPv4. When the TCP connect *succeeds*,
|
||||||
|
`v6.no_icmp_reply` is emitted at high confidence instead, now able to say plainly that ICMPv6 is
|
||||||
|
filtered while IPv6 works. With no server configured there is no corroboration target and the
|
||||||
|
two-explanation `v6.no_icmp_reply` stands unchanged.
|
||||||
|
|
||||||
|
`v6.not_offered` is **info and must stay info**. Most networks still do not offer IPv6 and that is
|
||||||
|
not a fault; reporting it as a warning lights a yellow verdict on a healthy network, which teaches
|
||||||
|
people to ignore the light — the one thing a diagnostic must never do.
|
||||||
|
|
||||||
|
## Adding a finding
|
||||||
|
|
||||||
|
1. Add a `FindingSpec` to `FindingRegistry`, and to its `all` list.
|
||||||
|
2. Add the row here, under the section its prefix names.
|
||||||
|
3. Emit it with `finding(FindingRegistry.YOUR_CODE, …)`.
|
||||||
|
|
||||||
|
The registry test checks 1 and 2 agree, that every prefix maps to the category it claims, and that
|
||||||
|
no two entries share a code.
|
||||||
@@ -52,12 +52,26 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
|||||||
},
|
},
|
||||||
"tiers": { "app": true, "shizuku": true, "root": false },
|
"tiers": { "app": true, "shizuku": true, "root": false },
|
||||||
"profiles_used": ["profile-uuid", ...],
|
"profiles_used": ["profile-uuid", ...],
|
||||||
|
"constraints": {
|
||||||
|
"vpn_active": true,
|
||||||
|
"per_network_blocked": true,
|
||||||
|
"unmeasured_networks": ["net-0", "net-1"]
|
||||||
|
},
|
||||||
"notes": "free-text user annotation"
|
"notes": "free-text user annotation"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`tiers` records what was *available*; each test records what it *used*.
|
`tiers` records what was *available*; each test records what it *used*.
|
||||||
|
|
||||||
|
`constraints` records what was *prevented*. A constrained run is neither a failed run nor a normal
|
||||||
|
one, and the distinction has to survive into the data: a run taken through a VPN has the same shape
|
||||||
|
and the same green verdict as a clean run of a healthy network, so without this a reader — or a
|
||||||
|
server aggregating thousands of them — cannot tell that almost nothing was measured. The known case
|
||||||
|
is `per_network_blocked`: Android refuses `Network.bindSocket()` on the underlying networks while a
|
||||||
|
VPN holds the default route, so every per-network test measures the tunnel or nothing at all, and
|
||||||
|
any conclusion about the link underneath is unfounded. Consumers should treat findings from a
|
||||||
|
constrained run as scoped to what was actually reachable, and `unmeasured_networks` names the rest.
|
||||||
|
|
||||||
## 4. `networks[]` — one entry per Android `Network` in play
|
## 4. `networks[]` — one entry per Android `Network` in play
|
||||||
|
|
||||||
A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ethernet). Everything is a snapshot at run start; a `changes[]` list captures mid-run deltas.
|
A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ethernet). Everything is a snapshot at run start; a `changes[]` list captures mid-run deltas.
|
||||||
@@ -269,7 +283,7 @@ The JSON Schema (machine-readable companion, `measurement.schema.json`, generate
|
|||||||
|
|
||||||
| type | example fields | v2 anonymizer transform |
|
| type | example fields | v2 anonymizer transform |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim |
|
| `ip4`, `ip6` | addresses, routes, hops, DNS answers | prefix-preserving pseudonymization, consistent per document; well-known/reserved ranges kept verbatim. **Exception: ULA (`fc00::/7`) has its whole prefix pseudonymized as a unit.** It resembles RFC1918 but is not analogous: a ULA global ID is 40 random bits, unique to one network by construction (RFC 4193), so the prefix *is* the identifier, whereas `192.168.0.0/16` is shared by millions of networks and identifies none. Pseudonymizing it as a unit keeps "these hosts are on one subnet" while dropping "this is that subnet". |
|
||||||
| `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized |
|
| `mac`, `bssid` | wifi, arp_watch | OUI kept, NIC part pseudonymized |
|
||||||
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
|
| `fqdn` | DNS names, reverse lookups | per-label pseudonyms, public-suffix kept |
|
||||||
| `ssid` | wifi | pseudonym |
|
| `ssid` | wifi | pseudonym |
|
||||||
@@ -279,7 +293,8 @@ Free-text fields (`notes`, `error.detail`, dump excerpts from Shizuku parsers) c
|
|||||||
|
|
||||||
## 9. Open items
|
## 9. Open items
|
||||||
|
|
||||||
1. Findings registry document — start alongside the first implemented tests.
|
1. ~~Findings registry document~~ — done: `findings-registry.md`, kept in step with
|
||||||
|
`FindingRegistry.kt` by a test that fails when the two disagree.
|
||||||
2. Whether Shizuku raw-dump excerpts (dumpsys/ip output) are embedded in `evidence` verbatim (auditable, but large and hard to anonymize) or parsed-only with an optional "attach raw dumps" toggle. Proposal: toggle, default on for local archive, default off for export.
|
2. Whether Shizuku raw-dump excerpts (dumpsys/ip output) are embedded in `evidence` verbatim (auditable, but large and hard to anonymize) or parsed-only with an optional "attach raw dumps" toggle. Proposal: toggle, default on for local archive, default off for export.
|
||||||
3. Peer-mode documents: each device produces its own run; the coordinator embeds the peer's findings summary and cross-references by `run.id`. Full merge format deferred.
|
3. Peer-mode documents: each device produces its own run; the coordinator embeds the peer's findings summary and cross-references by `run.id`. Full merge format deferred.
|
||||||
4. Size guardrails: soft cap 20 MB uncompressed per run; trains beyond that downsample evidence (keep aggregates + first/last N + all anomalies) and record `"evidence_truncated": true`.
|
4. Size guardrails: soft cap 20 MB uncompressed per run; trains beyond that downsample evidence (keep aggregates + first/last N + all anomalies) and record `"evidence_truncated": true`.
|
||||||
|
|||||||
@@ -24,11 +24,34 @@ echolot://enroll?v=1&u=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=
|
|||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/enroll Authorization: Bearer <enrollment-token>
|
POST /v1/enroll Authorization: Bearer <enrollment-token>
|
||||||
→ 200 { "device_credential": "<random 256-bit, b64url>",
|
→ 201 { "device_credential": "<random 256-bit, b64url>",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid" }
|
||||||
"profile": { ... §2.2 ... } }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The **server assembles the bootstrap link**, because it is the only party holding all three parts
|
||||||
|
at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail
|
||||||
|
loudly, it just never matches, and surfaces later as an inscrutable TLS error:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/enroll-tokens
|
||||||
|
→ { "token": "…", "expires_in_s": 86400,
|
||||||
|
"enroll_uri": "echolot://enroll?v=1&u=…&p=…&t=…" }
|
||||||
|
```
|
||||||
|
|
||||||
|
The control URL in the link comes from `ECHOLOT_PUBLIC_URL`, falling back to the first control
|
||||||
|
listen address. A wildcard bind has no single right answer, so it warns rather than guessing.
|
||||||
|
|
||||||
|
Encoding notes that matter in practice:
|
||||||
|
- `u`, `p` and `t` are **percent-encoded**. The pin is base64, so it contains `+`, `/` and `=`,
|
||||||
|
every one of which means something else in a query string.
|
||||||
|
- A `+` that was *not* encoded decodes to a space. Base64 contains no spaces, so a parser SHOULD
|
||||||
|
restore them — the alternative is a pin wrong by one character and a failure that points nowhere
|
||||||
|
near the cause.
|
||||||
|
- The control URL MUST be `https://`. The pin only protects a TLS connection; a cleartext URL
|
||||||
|
would hand the token to anyone on the path.
|
||||||
|
- **The link is a secret** while it is live: it carries a bearer token, so anyone who sees it
|
||||||
|
before the device does can enroll instead.
|
||||||
|
|
||||||
Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI.
|
Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI.
|
||||||
|
|
||||||
### 2.2 Profile
|
### 2.2 Profile
|
||||||
@@ -61,7 +84,12 @@ The app re-fetches the profile at the start of every run (falling back to the ca
|
|||||||
|
|
||||||
### 2.3 Capabilities (v1 registry)
|
### 2.3 Capabilities (v1 registry)
|
||||||
|
|
||||||
`udp-probe`, `stun-basic`, `stun-5780`, `canary-dns`, `recursive-dns`, `connect-back`, `delayed-echo`, `big-send`, `frag-send`, `tls-echo`, `http-echo`, `throughput`, `ntp`. A server omits what it can't offer (e.g. `stun-5780` without a second IP degrades to `stun-basic`). Clients must skip, and record as `unsupported`, any test whose capability is absent. Unknown capability strings are ignored.
|
`udp-probe`, `stun-basic`, `stun-5780`, `canary-dns`, `recursive-dns`, `connect-back`, `delayed-echo`, `big-send`, `frag-send`, `tls-echo`, `http-echo`, `throughput`, `ntp`, plus:
|
||||||
|
|
||||||
|
- `downtrain` — server-sent downstream trains via the §5 `downtrain` action. Upstream trains need no capability of their own: they are plain client-sent data-plane packets and ride `udp-probe`.
|
||||||
|
- `tcp-echo` — the plain-TCP echo endpoint (§4); `tls-echo` is its ALPN variant on the same port.
|
||||||
|
|
||||||
|
A server omits what it can't offer (e.g. `stun-5780` without a second IP degrades to `stun-basic`). Clients must skip, and record as `unsupported`, any test whose capability is absent. Unknown capability strings are ignored.
|
||||||
|
|
||||||
### 2.4 Sessions
|
### 2.4 Sessions
|
||||||
|
|
||||||
@@ -88,6 +116,31 @@ DELETE /v1/sessions/{id}
|
|||||||
|
|
||||||
Per-credential and per-source-IP token buckets on: session creation, actions, UDP packets, bytes. `429` on control plane; silent drop on data plane (probes must tolerate loss anyway). All reflected/generated traffic goes **only** to the session's observed source address (or, for connect-back, the source address of the session-creating request). Data-plane responses to unauthenticated packets are never larger than the request (§3.4).
|
Per-credential and per-source-IP token buckets on: session creation, actions, UDP packets, bytes. `429` on control plane; silent drop on data plane (probes must tolerate loss anyway). All reflected/generated traffic goes **only** to the session's observed source address (or, for connect-back, the source address of the session-creating request). Data-plane responses to unauthenticated packets are never larger than the request (§3.4).
|
||||||
|
|
||||||
|
### 2.2 `GET /v1/discover` — where the control plane lives
|
||||||
|
|
||||||
|
Unauthenticated, and says almost nothing: the control-plane URL and the server's display name.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "control_url": "https://probe.example.net", "name": "example" }
|
||||||
|
```
|
||||||
|
|
||||||
|
It exists so an enrollment link can carry the name a person recognises while the app still connects
|
||||||
|
to the name that selects the pinned certificate. When a server shares port 443 between its admin UI
|
||||||
|
and its control plane, those must be different hostnames — one port and one name is one certificate,
|
||||||
|
and the two need different ones (a browser-trusted certificate, and a long-lived self-signed one the
|
||||||
|
client pins). Without discovery, the difference leaks into every enrollment link an operator hands
|
||||||
|
out.
|
||||||
|
|
||||||
|
**It hands out an address, never a pin.** The pin travels in the link itself. Serving it here would
|
||||||
|
reduce pinning to whatever the certificate authorities are worth, and pinning exists precisely to
|
||||||
|
survive one the operator does not control — a root injected by corporate device management, for
|
||||||
|
instance, which is unremarkable on the networks this tool is pointed at. Because the pin is
|
||||||
|
pre-shared, an intercepted discovery response can only send a device to the wrong host, where the
|
||||||
|
pin will not match: an outage, not a compromise.
|
||||||
|
|
||||||
|
Clients treat it as optional. A server that does not answer, or a link that already names the
|
||||||
|
control endpoint, works unchanged — enrollment must not begin failing because a lookup did.
|
||||||
|
|
||||||
## 3. UDP probe protocol
|
## 3. UDP probe protocol
|
||||||
|
|
||||||
### 3.1 Packet header (fixed 32 bytes, network byte order)
|
### 3.1 Packet header (fixed 32 bytes, network byte order)
|
||||||
@@ -192,14 +245,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,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,91 @@
|
|||||||
|
// 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.3"
|
||||||
|
|
||||||
|
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).
|
||||||
|
// Empty: the collection endpoint this pointed at was the adb-beacon receiver, which held
|
||||||
|
// 0.0.0.0:443 in cleartext. That service is gone and echolot-server owns 443 with TLS, so
|
||||||
|
// posting plaintext there now fails as "client sent an HTTP request to an HTTPS server" —
|
||||||
|
// an alarming error for a debugging convenience that is no longer needed, since autorun
|
||||||
|
// reports are read straight off the device with `run-as cat`.
|
||||||
|
//
|
||||||
|
// Deliberately not repointed at /v1/runs. That is the consent-gated upload, and a
|
||||||
|
// debugging shortcut must not be able to satisfy it by accident.
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_URL", "\"\"")
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"\"")
|
||||||
|
// 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,66 @@
|
|||||||
|
<?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"
|
||||||
|
android:launchMode="singleTask">
|
||||||
|
<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>
|
||||||
|
<!--
|
||||||
|
Sign-in redirect. The browser hands the authorization code back through this, which
|
||||||
|
is exactly why the flow uses PKCE: any app may register this scheme, so the code
|
||||||
|
alone must not be enough to complete a sign-in.
|
||||||
|
-->
|
||||||
|
<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="auth" />
|
||||||
|
</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,125 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.AuthInfo
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.OidcLogin
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing in to the configured server's identity provider.
|
||||||
|
*
|
||||||
|
* The awkward part of a browser-based sign-in on Android is that the app is not running while it
|
||||||
|
* happens. Handing control to a browser puts this process in the background, where it may be
|
||||||
|
* killed at any moment; the callback then arrives at a fresh process with none of the state the
|
||||||
|
* exchange needs. So the PKCE verifier and state are written to storage before the browser opens,
|
||||||
|
* not held in memory — an in-memory value works on a developer's device and fails on a phone under
|
||||||
|
* memory pressure, which is the worst way for this to break.
|
||||||
|
*
|
||||||
|
* Nothing from the identity provider is kept afterwards. The ID token proves who is signing in,
|
||||||
|
* once; the device credential authenticates everything from then on.
|
||||||
|
*/
|
||||||
|
class Account(private val settings: Settings) {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
sealed interface SignInStart {
|
||||||
|
/** Open this in a browser. */
|
||||||
|
data class Browser(val url: String) : SignInStart
|
||||||
|
data class Unavailable(val reason: String) : SignInStart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the server's auth configuration and builds the authorization URL. */
|
||||||
|
fun begin(): SignInStart {
|
||||||
|
if (!settings.serverConfigured) {
|
||||||
|
return SignInStart.Unavailable(
|
||||||
|
"Enrol with a server first — sign-in belongs to the server's identity provider."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val auth = runCatching { client().profile(settings.serverCredential).auth }.getOrNull()
|
||||||
|
?: return SignInStart.Unavailable("Could not reach the server to ask how to sign in.")
|
||||||
|
|
||||||
|
auth.discoveryError?.let {
|
||||||
|
// The distinction matters: "the operator configured an IdP that is not answering" is
|
||||||
|
// their problem to fix, and is not the same as "this server has no accounts".
|
||||||
|
return SignInStart.Unavailable("The server's identity provider is not responding: $it")
|
||||||
|
}
|
||||||
|
if (!auth.enabled) {
|
||||||
|
return SignInStart.Unavailable("This server does not offer accounts.")
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val pending = OidcLogin.begin(auth)
|
||||||
|
// Written before the browser opens, because after that this process may not survive.
|
||||||
|
settings.pendingVerifier = pending.verifier
|
||||||
|
settings.pendingState = pending.state
|
||||||
|
SignInStart.Browser(pending.authorizationUrl)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
SignInStart.Unavailable(t.message ?: "Could not start sign-in.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes sign-in from the `echolot://auth` redirect.
|
||||||
|
*
|
||||||
|
* Blocking; callers run it off the main thread.
|
||||||
|
*/
|
||||||
|
fun complete(callbackUri: String): String {
|
||||||
|
val verifier = settings.pendingVerifier
|
||||||
|
val state = settings.pendingState
|
||||||
|
// Cleared first, whatever happens next: these are single-use, and leaving them behind
|
||||||
|
// would let a later callback be completed against a flow nobody started.
|
||||||
|
settings.clearPendingAuth()
|
||||||
|
|
||||||
|
if (verifier.isBlank() || state.isBlank()) {
|
||||||
|
return "That sign-in did not start on this device."
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val auth = client().profile(settings.serverCredential).auth
|
||||||
|
val idToken = OidcLogin.complete(
|
||||||
|
auth, OidcLogin.Pending("", verifier, state), callbackUri,
|
||||||
|
)
|
||||||
|
val reply = client().linkAccount(settings.serverCredential, idToken)
|
||||||
|
val o = json.parseToJsonElement(reply).jsonObject
|
||||||
|
val name = o["display_name"]?.jsonPrimitive?.content ?: "signed in"
|
||||||
|
settings.accountName = name
|
||||||
|
settings.accountId = o["account_id"]?.jsonPrimitive?.content ?: ""
|
||||||
|
val admin = o["admin"]?.jsonPrimitive?.content == "true"
|
||||||
|
"Signed in as $name" + if (admin) " (administrator)" else ""
|
||||||
|
} catch (e: OidcLogin.LoginFailed) {
|
||||||
|
e.message ?: "Sign-in failed."
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
"Sign-in failed: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signs out. The device stays enrolled — signing out should not cost an enrolment. */
|
||||||
|
fun signOut(): String = try {
|
||||||
|
client().unlinkAccount(settings.serverCredential)
|
||||||
|
settings.accountName = ""
|
||||||
|
settings.accountId = ""
|
||||||
|
"Signed out. This device is still enrolled."
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
"Could not sign out: ${t.message ?: t.javaClass.simpleName}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks the server who it thinks is signed in, so the UI is not trusting stale local state. */
|
||||||
|
fun refresh(): String? = runCatching {
|
||||||
|
val o = json.parseToJsonElement(client().accountStatus(settings.serverCredential)).jsonObject
|
||||||
|
val signedIn = o["signed_in"]?.jsonPrimitive?.content == "true"
|
||||||
|
settings.accountName = if (signedIn) {
|
||||||
|
o["display_name"]?.jsonPrimitive?.content ?: ""
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
settings.accountName.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun client() = ControlClient(
|
||||||
|
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
||||||
|
fallbackAddrs = settings.serverAddrList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 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.safeDrawingPadding
|
||||||
|
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().safeDrawingPadding().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 · " +
|
||||||
|
"kept complete on this device",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
// The upload line names the level the upload was made at, not the
|
||||||
|
// archive's. They describe different documents, and showing the archive's
|
||||||
|
// level here claimed more had left the device than actually did.
|
||||||
|
Text(
|
||||||
|
if (r.uploaded) {
|
||||||
|
"uploaded to ${r.uploadedTo ?: "a server"}" +
|
||||||
|
(r.uploadedAs?.let { " as $it" } ?: "")
|
||||||
|
} 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,590 @@
|
|||||||
|
// 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 */ }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intent currently being acted on, so a deep link that arrives while the app is running
|
||||||
|
* is seen by the screen the user is already looking at.
|
||||||
|
*
|
||||||
|
* The activity is singleTask for the same reason. As a standard activity it stacked a second
|
||||||
|
* instance per link, each with its own ViewModel: the enrolment then happened in a throwaway
|
||||||
|
* copy, and pressing back returned to the original screen showing none of it. Silent, and
|
||||||
|
* indistinguishable from the link simply not working.
|
||||||
|
*/
|
||||||
|
private val liveIntent = mutableStateOf<android.content.Intent?>(null)
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
liveIntent.value = intent
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
requestRuntimePermissions()
|
||||||
|
liveIntent.value = intent
|
||||||
|
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.
|
||||||
|
// Both deep links land here. They are told apart by host, so a sign-in
|
||||||
|
// redirect is never mistaken for an enrolment link — one spends a token, the
|
||||||
|
// other completes an authorization, and confusing them would fail obscurely.
|
||||||
|
val incoming = liveIntent.value?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
||||||
|
val authUri = incoming?.takeIf { it.startsWith("echolot://auth") }
|
||||||
|
val enrollUri = incoming?.takeIf { it.startsWith("echolot://enroll") }
|
||||||
|
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
||||||
|
if (enrollUri != null) {
|
||||||
|
vm.enroll(enrollUri)
|
||||||
|
screen = Screen.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replacing an existing enrollment is asked about, never assumed. Following a
|
||||||
|
// link from a web page is one tap, and the old credential does not survive it.
|
||||||
|
vm.state.pendingEnroll?.let { pending ->
|
||||||
|
androidx.compose.material3.AlertDialog(
|
||||||
|
onDismissRequest = { vm.cancelEnroll() },
|
||||||
|
title = {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
if (pending.sameServer) "Enroll again with this server?"
|
||||||
|
else "Replace this device's server?"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
// Naming the same URL twice reads as a mistake and buries the
|
||||||
|
// one consequence that actually applies: the device is issued a
|
||||||
|
// fresh credential and shows up as a second entry.
|
||||||
|
if (pending.sameServer) {
|
||||||
|
"This device is already enrolled with " +
|
||||||
|
"${pending.currentServer}.\n\n" +
|
||||||
|
"Enrolling again replaces its credential. The old one " +
|
||||||
|
"stops working immediately, and the device appears on " +
|
||||||
|
"the server as a new entry alongside the current one — " +
|
||||||
|
"which you may want to revoke afterwards.\n\n" +
|
||||||
|
"Runs already uploaded, and runs stored on this phone, " +
|
||||||
|
"are not affected."
|
||||||
|
} else {
|
||||||
|
"This device is already enrolled with " +
|
||||||
|
"${pending.currentServer}.\n\n" +
|
||||||
|
"Enrolling with ${pending.newServer} replaces that. Runs " +
|
||||||
|
"already uploaded stay where they are, but this device " +
|
||||||
|
"stops reporting to the old server and appears on the new " +
|
||||||
|
"one as a new device.\n\n" +
|
||||||
|
"Runs stored on this phone are not affected."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { vm.confirmEnroll() }) {
|
||||||
|
androidx.compose.material3.Text(
|
||||||
|
if (pending.sameServer) "Enroll again" else "Enroll here"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
androidx.compose.material3.TextButton(onClick = { vm.cancelEnroll() }) {
|
||||||
|
androidx.compose.material3.Text("Keep current server")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
androidx.compose.runtime.LaunchedEffect(authUri) {
|
||||||
|
if (authUri != null) {
|
||||||
|
vm.completeSignIn(authUri)
|
||||||
|
screen = Screen.SETTINGS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shizuku can be started, stopped or authorised in its own app, where nothing
|
||||||
|
// calls back into this process. Asking again each time this screen comes
|
||||||
|
// forward is what makes the banner right after the user has been away to fix
|
||||||
|
// it — which is exactly the moment they look at it.
|
||||||
|
val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current
|
||||||
|
androidx.compose.runtime.DisposableEffect(lifecycleOwner) {
|
||||||
|
val obs = androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||||
|
if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) {
|
||||||
|
vm.refreshShizuku()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lifecycleOwner.lifecycle.addObserver(obs)
|
||||||
|
onDispose { lifecycleOwner.lifecycle.removeObserver(obs) }
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Without this, the system Back gesture leaves the activity from Settings or
|
||||||
|
// History instead of returning to the run screen — the screen is a plain state
|
||||||
|
// variable, so nothing connects it to the back stack. Registered only when
|
||||||
|
// there is somewhere to go back to, so Back still exits from the run screen.
|
||||||
|
androidx.activity.compose.BackHandler(enabled = screen != Screen.RUN) {
|
||||||
|
screen = Screen.RUN
|
||||||
|
}
|
||||||
|
|
||||||
|
when (screen) {
|
||||||
|
Screen.SETTINGS -> SettingsScreen(
|
||||||
|
settings = vm.settings,
|
||||||
|
archivedRuns = vm.archivedRunCount(),
|
||||||
|
archivedBytes = vm.archivedBytes(),
|
||||||
|
onApplyRetention = vm::applyRetention,
|
||||||
|
onDeleteAll = vm::deleteAllRuns,
|
||||||
|
onPreviewUpload = {
|
||||||
|
// Straight from the archive: the newest run is the one the user just
|
||||||
|
// made and the one they are deciding about. Always shows something,
|
||||||
|
// even when there is nothing to preview yet.
|
||||||
|
lifecycleScope.launch { preview = vm.previewNewestRun() }
|
||||||
|
},
|
||||||
|
onCheckServer = vm::checkServer,
|
||||||
|
accountName = vm.accountName,
|
||||||
|
onSignIn = {
|
||||||
|
vm.beginSignIn { url ->
|
||||||
|
// A plain VIEW intent rather than a Custom Tab: the browser is
|
||||||
|
// where the user's existing IdP session already lives, and
|
||||||
|
// androidx.browser would be a dependency for a rounded corner.
|
||||||
|
runCatching {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, android.net.Uri.parse(url)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSignOut = vm::signOut,
|
||||||
|
onEnroll = vm::enroll,
|
||||||
|
serverStatus = vm.state.archiveStatus,
|
||||||
|
enrollStatus = vm.state.enrollStatus,
|
||||||
|
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) {
|
||||||
|
// A constrained run is answered before the lights are: the verdict below is INCONCLUSIVE by
|
||||||
|
// §7.3, and without this banner "inconclusive" reads as the app failing rather than the OS
|
||||||
|
// (correctly) refusing to let anything past the VPN be measured.
|
||||||
|
val constraints = doc.run.constraints
|
||||||
|
if (constraints.constrained) {
|
||||||
|
val blocked = constraints.unmeasuredNetworks
|
||||||
|
.mapNotNull { id -> doc.networks.firstOrNull { it.id == id } }
|
||||||
|
.joinToString(", ") { it.iface?.takeIf { s -> s.isNotBlank() } ?: it.transport.name.lowercase() }
|
||||||
|
.ifBlank { "the networks beneath it" }
|
||||||
|
// Same three-way split as the finding: saying "VPN" when the user just disconnected
|
||||||
|
// theirs (the wall lingers during teardown) reads as the app being wrong, not the OS.
|
||||||
|
val (headline, body) = when {
|
||||||
|
constraints.vpnActive && constraints.perNetworkBlocked ->
|
||||||
|
"Measured through a VPN" to
|
||||||
|
("Android does not let apps send on the networks beneath an active VPN, so " +
|
||||||
|
"$blocked could not be measured — these results describe the tunnel. " +
|
||||||
|
"Disconnect the VPN and run again to measure the networks themselves.")
|
||||||
|
constraints.perNetworkBlocked ->
|
||||||
|
"Some networks could not be measured" to
|
||||||
|
("Android refused sends on $blocked — the restriction a VPN leaves in place " +
|
||||||
|
"while it tears down. These networks went unmeasured; wait a few " +
|
||||||
|
"seconds and run again.")
|
||||||
|
else ->
|
||||||
|
"A VPN holds the default route" to
|
||||||
|
("Default-route results describe the tunnel; per-network measurements " +
|
||||||
|
"reached the underlying networks.")
|
||||||
|
}
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = Color(0xFF3A2E12))) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||||
|
Text(headline, color = Color(0xFFFFD08A), fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(body, fontSize = 12.sp, color = Color(0xFFFFD08A))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,228 @@
|
|||||||
|
// 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,
|
||||||
|
fallbackAddrs = settings.serverAddrList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Remembers where the server lives, so a later run can reach it without DNS. */
|
||||||
|
private fun rememberAddrs(p: app.echo_lot.protocol.Profile) {
|
||||||
|
val addrs = p.targets.flatMap { listOfNotNull(it.ip4, it.ip6) }
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
if (addrs.isNotEmpty()) settings.serverAddrs = addrs.joinToString(",")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)
|
||||||
|
// Learned here so the next run's canary probe knows what to ask for.
|
||||||
|
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||||
|
settings.serverFacts = describeFacts(profile)
|
||||||
|
rememberAddrs(profile)
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders what the server says about itself, for display.
|
||||||
|
*
|
||||||
|
* Only what a person measuring against it would want to check: which addresses the tests will
|
||||||
|
* actually use, on which ports, and what the server admits it can do. Addresses first, because
|
||||||
|
* "which address did this result come from" is the question a report leaves open.
|
||||||
|
*/
|
||||||
|
private fun describeFacts(p: app.echo_lot.protocol.Profile): String {
|
||||||
|
val lines = ArrayList<String>()
|
||||||
|
// "label|value" per line, laid out as real columns by the UI rather than padded with
|
||||||
|
// spaces here. Space padding only lines up in a monospaced font, which makes the layout
|
||||||
|
// depend on a typeface choice made somewhere else entirely.
|
||||||
|
fun row(label: String, value: String) = lines.add("$label|$value")
|
||||||
|
|
||||||
|
row("server", "${p.name} · ${p.serverVersion}")
|
||||||
|
for (t in p.targets) {
|
||||||
|
t.ip4?.let { row("IPv4", it) }
|
||||||
|
t.ip6?.let { row("IPv6", it) }
|
||||||
|
// Marked rather than listed apart: it is the same server, and what matters is being
|
||||||
|
// able to tell which address a NAT-behaviour result came from.
|
||||||
|
t.ip4Alt?.let { row("IPv4 alt", it) }
|
||||||
|
t.ip6Alt?.let { row("IPv6 alt", it) }
|
||||||
|
row("ports", "udp ${t.udpPort} · tcp ${t.tcpPort} · stun ${t.stunPort}")
|
||||||
|
}
|
||||||
|
if (p.canaryZone.isNotBlank()) row("dns zone", p.canaryZone)
|
||||||
|
if (p.capabilities.isNotEmpty()) row("measures", p.capabilities.joinToString(", "))
|
||||||
|
return lines.joinToString(System.lineSeparator())
|
||||||
|
}
|
||||||
|
|
||||||
|
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.serverFacts = describeFacts(enrolled.profile)
|
||||||
|
rememberAddrs(enrolled.profile)
|
||||||
|
enrolled.profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||||
|
settings.serverUrl = enrolled.controlUrl
|
||||||
|
settings.serverPublicUrl = enrolled.publicUrl
|
||||||
|
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)
|
||||||
|
profile.canaryZone.takeIf { it.isNotBlank() }?.let { settings.canaryZone = it }
|
||||||
|
settings.serverFacts = describeFacts(profile)
|
||||||
|
rememberAddrs(profile)
|
||||||
|
|
||||||
|
// 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, level.wire)
|
||||||
|
// Deliberately not echoing `reply`: it is the server's index entry as raw JSON, and
|
||||||
|
// it ended up rendered verbatim in the UI. Size and level are what a person wants.
|
||||||
|
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes")
|
||||||
|
} 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,941 @@
|
|||||||
|
// 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(),
|
||||||
|
/**
|
||||||
|
* Result of the last enrollment attempt, shown beside the Enroll button.
|
||||||
|
*
|
||||||
|
* Separate from [archiveStatus]: they are two different actions with two different results,
|
||||||
|
* and sharing one line put the answer to "did enrolling work" at the far end of the card,
|
||||||
|
* below three text fields — or nowhere at all on a fresh install, since that line only
|
||||||
|
* renders once a run exists.
|
||||||
|
*/
|
||||||
|
val enrollStatus: String? = null,
|
||||||
|
/** An enrollment link waiting on confirmation, because this device is already enrolled. */
|
||||||
|
val pendingEnroll: PendingEnroll? = null,
|
||||||
|
/** 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An enrollment link that would replace an existing one, held until the user says so.
|
||||||
|
*
|
||||||
|
* Enrolling is not additive: the new credential replaces the old, and on the previous server this
|
||||||
|
* device simply stops reporting. Following a link is one tap from a web page, which is not enough
|
||||||
|
* deliberation to discard a working enrollment by accident.
|
||||||
|
*/
|
||||||
|
data class PendingEnroll(val link: String, val currentServer: String, val newServer: String) {
|
||||||
|
/** Re-enrolling with the server already configured, rather than moving to a different one. */
|
||||||
|
val sameServer: Boolean get() = currentServer.trimEnd('/') == newServer.trimEnd('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads the shell tier's state, for when it changed somewhere this process cannot see.
|
||||||
|
*
|
||||||
|
* Permission can be granted inside Shizuku's own app, and Shizuku can be started or stopped
|
||||||
|
* there too; none of that calls back here. Asking again on resume is the only way to be right
|
||||||
|
* after the user has been somewhere else to fix it.
|
||||||
|
*/
|
||||||
|
fun refreshShizuku() {
|
||||||
|
val st = ShizukuAvailability.current(getApplication())
|
||||||
|
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
|
||||||
|
private var runConstraints = Constraints()
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
runConstraints = Constraints()
|
||||||
|
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 = when {
|
||||||
|
r.ok -> "uploaded ✓ ${r.detail}"
|
||||||
|
// Not a failure worth alarming about: the dev collection endpoint is simply
|
||||||
|
// not configured, and the run is on the device either way.
|
||||||
|
r.detail.startsWith("no upload URL") -> "run complete — read it with adb"
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview of the most recent run, read from the archive rather than from [UiState.history].
|
||||||
|
*
|
||||||
|
* The history list is only populated once the History screen has been opened, so a preview
|
||||||
|
* driven from it did nothing at all on a freshly-opened Settings screen — a button that
|
||||||
|
* silently does nothing is worse than one that says why.
|
||||||
|
*/
|
||||||
|
suspend fun previewNewestRun(): String = withContext(Dispatchers.IO) {
|
||||||
|
val newest = store.list().firstOrNull()
|
||||||
|
?: return@withContext "No archived runs yet. Run a measurement first, then this will " +
|
||||||
|
"show exactly what an upload would send."
|
||||||
|
store.read(newest.id)?.let { store.redactedForUpload(it) }
|
||||||
|
?: "That run could not be read back from the archive."
|
||||||
|
}
|
||||||
|
|
||||||
|
fun archivedBytes(): Long = store.totalBytes()
|
||||||
|
|
||||||
|
/** Counted from the archive itself, not from [UiState.history], which is empty until the
|
||||||
|
* history screen has been opened - the two disagreeing read as data loss. */
|
||||||
|
fun archivedRunCount(): Int = store.list().size
|
||||||
|
|
||||||
|
private val account = Account(settings)
|
||||||
|
|
||||||
|
/** Name of whoever is signed in on this device, for the settings screen. */
|
||||||
|
var accountName by mutableStateOf(settings.accountName)
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Starts sign-in; the caller opens the returned URL in a browser. */
|
||||||
|
fun beginSignIn(open: (String) -> Unit) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(archiveStatus = "contacting the server …")
|
||||||
|
when (val r = withContext(Dispatchers.IO) { account.begin() }) {
|
||||||
|
is Account.SignInStart.Browser -> {
|
||||||
|
state = state.copy(archiveStatus = "continue in your browser …")
|
||||||
|
open(r.url)
|
||||||
|
}
|
||||||
|
is Account.SignInStart.Unavailable ->
|
||||||
|
state = state.copy(archiveStatus = r.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Completes sign-in from the echolot://auth redirect. */
|
||||||
|
fun completeSignIn(callbackUri: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val msg = withContext(Dispatchers.IO) { account.complete(callbackUri) }
|
||||||
|
accountName = settings.accountName
|
||||||
|
state = state.copy(archiveStatus = msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signOut() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val msg = withContext(Dispatchers.IO) { account.signOut() }
|
||||||
|
accountName = settings.accountName
|
||||||
|
state = state.copy(archiveStatus = msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
|
||||||
|
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
||||||
|
// Already enrolled? Ask first. The old credential is gone the moment this succeeds, and a
|
||||||
|
// link followed from a web page is one tap — far too little deliberation for that.
|
||||||
|
if (settings.serverConfigured) {
|
||||||
|
val target = app.echo_lot.protocol.EnrollmentLink.parse(link)?.controlUrl ?: link
|
||||||
|
state = state.copy(
|
||||||
|
pendingEnroll = PendingEnroll(
|
||||||
|
link = link,
|
||||||
|
// Compared against the link's URL, which names the server publicly — so this
|
||||||
|
// has to be the public name too. Using the endpoint made re-enrolling with the
|
||||||
|
// same server look like a move to a different one, because the endpoint and
|
||||||
|
// the public name are deliberately different strings.
|
||||||
|
currentServer = settings.serverPublicUrl,
|
||||||
|
newServer = target,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doEnroll(link, deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user confirmed replacing an existing enrollment. */
|
||||||
|
fun confirmEnroll(deviceName: String? = android.os.Build.MODEL) {
|
||||||
|
val pending = state.pendingEnroll ?: return
|
||||||
|
state = state.copy(pendingEnroll = null)
|
||||||
|
doEnroll(pending.link, deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelEnroll() {
|
||||||
|
state = state.copy(
|
||||||
|
pendingEnroll = null,
|
||||||
|
enrollStatus = "Kept the existing enrollment; nothing changed.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun doEnroll(link: String, deviceName: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
state = state.copy(enrollStatus = "Enrolling …")
|
||||||
|
val result = withContext(Dispatchers.IO) { store.enroll(link, deviceName) }
|
||||||
|
// A new server means a new canary zone; the old one would describe somebody else's
|
||||||
|
// deployment. Cleared rather than kept, and relearned from the next profile fetch.
|
||||||
|
settings.canaryZone = ""
|
||||||
|
state = state.copy(enrollStatus = result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 }
|
||||||
|
|
||||||
|
// What will this run be prevented from measuring? Decided up front, from one throwaway
|
||||||
|
// bind per network, so the document can say so instead of leaving it to be inferred from
|
||||||
|
// per-test `attempted: false` breadcrumbs (measurement-schema.md §3 `constraints`).
|
||||||
|
runConstraints = app.echo_lot.probe.ConstraintDetector.detect(ctx, entries)
|
||||||
|
|
||||||
|
val probes: List<Probe> = listOf(
|
||||||
|
LinkSnapshotProbe(entries),
|
||||||
|
RouterIdentityProbe(entries),
|
||||||
|
IcmpProbe(entries, v6 = false),
|
||||||
|
IcmpProbe(entries, v6 = true),
|
||||||
|
// Folded from the prober after hardware validation: errqueue traceroute (no root,
|
||||||
|
// no JNI) and the mDNS service inventory / VLAN-leakage detector.
|
||||||
|
app.echo_lot.probe.TracerouteProbe(),
|
||||||
|
app.echo_lot.probe.MdnsInventoryProbe(),
|
||||||
|
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.
|
||||||
|
// Both target whatever server this device is enrolled with, not the deployment the
|
||||||
|
// app happened to be developed against. With no server configured they get blank
|
||||||
|
// strings and report themselves skipped, which is the honest outcome — the
|
||||||
|
// alternative measures someone else's infrastructure and calls it your network.
|
||||||
|
// Before the canary: "can this device resolve at all" has to be answered before
|
||||||
|
// "are the answers being tampered with" means anything.
|
||||||
|
app.echo_lot.probe.DnsResolverProbe(entries),
|
||||||
|
DnsCanaryProbe(canaryZone = settings.canaryZone, sessionPrefix = "adhoc"),
|
||||||
|
StunProbe(serverHost = settings.serverHost()),
|
||||||
|
// Corroboration for icmp.ping6's silence: a real TCP connection over IPv6. Only its
|
||||||
|
// failure, on a network that advertises IPv6, justifies calling IPv6 broken.
|
||||||
|
app.echo_lot.probe.V6ConnectProbe(entries, serverHost = settings.serverHost()),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
|
// the bar reflects the whole run. Estimates prefer what THIS device measured on recent
|
||||||
|
// runs (Settings EMA); Probe.estimatedMs is only the cold-start seed — a fixed table
|
||||||
|
// cannot know whether ICMPv6 answers in milliseconds here or waits out its timeout.
|
||||||
|
fun estimateOf(p: Probe) = settings.learnedDurationMs(p.type) ?: p.estimatedMs
|
||||||
|
val shizukuEstimateMs = settings.learnedDurationMs(SHIZUKU_DURATION_KEY) ?: 8_000L
|
||||||
|
val totalSteps = probes.size + 1
|
||||||
|
var remainingMs = probes.sumOf { estimateOf(it) } + 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)
|
||||||
|
settings.recordDurationMs(p.type, (result.endedMonoNs - result.startedMonoNs) / 1_000_000)
|
||||||
|
remainingMs -= estimateOf(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running. One
|
||||||
|
// battery, three tests: the raw captures plus the parsed ra_source/arp_watch views.
|
||||||
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
|
val shizukuT0 = System.nanoTime()
|
||||||
|
val shizukuTests = try {
|
||||||
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
listOf(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),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
// One key for the whole step: the three tests come out of one shell battery, and the
|
||||||
|
// bar plans them as one step.
|
||||||
|
settings.recordDurationMs(SHIZUKU_DURATION_KEY, (System.nanoTime() - shizukuT0) / 1_000_000)
|
||||||
|
tests.addAll(shizukuTests); collected.addAll(shizukuTests)
|
||||||
|
// "Shizuku tier ran" is the battery's verdict — the derived tests can be PARTIAL on a
|
||||||
|
// perfectly healthy shell tier (e.g. an RA-less v4-only link).
|
||||||
|
runShizukuOk = shizukuTests.any {
|
||||||
|
it.type == TestType.LINK_IP_MONITOR &&
|
||||||
|
(it.status == TestStatus.OK || it.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),
|
||||||
|
constraints = runConstraints,
|
||||||
|
),
|
||||||
|
networks = runNetworks,
|
||||||
|
tests = tests,
|
||||||
|
findings = findings,
|
||||||
|
summary = Verdicts.derive(tests, findings, runConstraints),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was IPv6 provisioned on the network a test actually ran over?
|
||||||
|
*
|
||||||
|
* This deliberately asks about one network rather than about the device. Answering "does any
|
||||||
|
* network here have IPv6" produces a real false positive on a phone, and it is not hypothetical:
|
||||||
|
* an IPv4-only wifi with working cellular alongside it reports "IPv6 is configured, but ICMPv6
|
||||||
|
* gets no reply" — configured on cellular, pinged over wifi, and the two never met.
|
||||||
|
*
|
||||||
|
* A global (non-link-local) address or a v6 default route means the network claims to offer
|
||||||
|
* IPv6; link-local only does not count, since every interface has one.
|
||||||
|
*/
|
||||||
|
private fun ipv6Provisioned(
|
||||||
|
networks: List<app.echo_lot.measurement.Network>,
|
||||||
|
networkRef: String?,
|
||||||
|
): Boolean {
|
||||||
|
// No reference means the test was not per-network; fall back to the device-wide reading
|
||||||
|
// rather than silently reporting nothing.
|
||||||
|
val scope = networks.filter { networkRef == null || it.id == networkRef }
|
||||||
|
return scope.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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-network ICMP outcomes, keyed by network id.
|
||||||
|
*
|
||||||
|
* Reads the structured evidence the probe records rather than its prose detail — a finding
|
||||||
|
* that depended on the wording of a human-readable string would break silently the first time
|
||||||
|
* that wording improved.
|
||||||
|
*/
|
||||||
|
/** What one network's ICMP attempt did: whether it ran at all, and whether it was answered. */
|
||||||
|
private data class IcmpOutcome(val attempted: Boolean, val ok: Boolean)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-network ICMP outcomes, keyed by network id.
|
||||||
|
*
|
||||||
|
* Reads the structured evidence the probe records rather than its prose detail — a finding
|
||||||
|
* that depended on the wording of a human-readable string would break silently the first time
|
||||||
|
* that wording improved.
|
||||||
|
*/
|
||||||
|
private fun icmpResults(t: Test): Map<String, IcmpOutcome> {
|
||||||
|
val out = HashMap<String, IcmpOutcome>()
|
||||||
|
val ev = t.evidence ?: return out
|
||||||
|
for ((_, v) in ev) {
|
||||||
|
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
|
val ref = (o["network_ref"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: continue
|
||||||
|
fun flag(k: String) = (o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
|
||||||
|
out[ref] = IcmpOutcome(attempted = flag("attempted"), ok = flag("ok"))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-facing name for the network a test ran over; falls back to something readable. */
|
||||||
|
private fun ifaceOf(networks: List<app.echo_lot.measurement.Network>, ref: String?): String =
|
||||||
|
networks.firstOrNull { it.id == ref }?.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
|
||||||
|
/** 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()
|
||||||
|
val linkEvidence = tests.filter { it.type == TestType.LINK_SNAPSHOT }.map { EvidenceRef(it.id) }
|
||||||
|
|
||||||
|
// Said as a finding, not only as run.constraints: the constraints block is for machines
|
||||||
|
// aggregating thousands of runs, this is for the person reading this one. Both must exist —
|
||||||
|
// a constrained run with a quiet findings list still reads as "nothing wrong here".
|
||||||
|
if (runConstraints.constrained) {
|
||||||
|
val blocked = runConstraints.unmeasuredNetworks
|
||||||
|
.joinToString(", ") { id -> ifaceOf(networks, id) }
|
||||||
|
.ifBlank { "the underlying networks" }
|
||||||
|
// Three distinct situations share this finding code, and naming the wrong one costs
|
||||||
|
// trust: claiming "a VPN is active" right after the user disconnected theirs is how
|
||||||
|
// this text was first proven wrong on hardware.
|
||||||
|
val (title, description) = when {
|
||||||
|
runConstraints.vpnActive && runConstraints.perNetworkBlocked ->
|
||||||
|
"A VPN is active — $blocked could not be measured" to
|
||||||
|
("Android refuses to let apps send on the networks beneath an active " +
|
||||||
|
"VPN (that is how it prevents traffic leaking around the tunnel), " +
|
||||||
|
"so every per-network test here measured the tunnel or nothing. " +
|
||||||
|
"Nothing in this run says anything about $blocked. To measure them, " +
|
||||||
|
"disconnect the VPN and run again.")
|
||||||
|
runConstraints.perNetworkBlocked ->
|
||||||
|
"The OS refused sends on $blocked" to
|
||||||
|
("Android denied this app permission to send on $blocked (EPERM on " +
|
||||||
|
"bind). That is the restriction a VPN imposes on the networks " +
|
||||||
|
"beneath it — a tunnel disconnected moments ago can still leave it " +
|
||||||
|
"in place while it tears down. Nothing in this run says anything " +
|
||||||
|
"about $blocked; wait a few seconds and run again.")
|
||||||
|
else ->
|
||||||
|
"A VPN holds the default route" to
|
||||||
|
("Everything using the default route in this run describes the tunnel, " +
|
||||||
|
"not the network it rides on. Per-network measurements were " +
|
||||||
|
"permitted and did measure the underlying networks.")
|
||||||
|
}
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.code,
|
||||||
|
category = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.category,
|
||||||
|
severity = FindingRegistry.MEASUREMENT_VPN_CONSTRAINED.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
evidenceRefs = linkEvidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val shapes = V6Analysis.classify(networks)
|
||||||
|
// Named per interface: on a phone several networks are up at once, and "IPv6 is broken" is
|
||||||
|
// useless when wifi is the broken one and cellular is fine.
|
||||||
|
for (sh in shapes.filter { it.addressWithoutRoute }) {
|
||||||
|
val where = if (sh.iface.isBlank()) "This device" else sh.iface
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.V6_NO_DEFAULT_ROUTE.code,
|
||||||
|
category = FindingRegistry.V6_NO_DEFAULT_ROUTE.category,
|
||||||
|
severity = if (sh.tunnel) Severity.INFO else Severity.MEDIUM,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = if (sh.tunnel) {
|
||||||
|
"IPv6 reaches only the destinations a tunnel routes ($where)"
|
||||||
|
} else {
|
||||||
|
"IPv6 address with no default route ($where)"
|
||||||
|
},
|
||||||
|
description = "$where has a global IPv6 address but no IPv6 default route, so " +
|
||||||
|
"IPv6 reaches only destinations covered by a specific route. " +
|
||||||
|
if (sh.tunnel) {
|
||||||
|
"A tunnel interface holds those routes, so this looks deliberate. " +
|
||||||
|
"Worth knowing rather than fixing: applications holding a global " +
|
||||||
|
"address will still try IPv6 first and stall for anything outside " +
|
||||||
|
"the tunnel's routes."
|
||||||
|
} else {
|
||||||
|
"Nothing is routing the rest, so the network handed out an address it " +
|
||||||
|
"does not carry traffic for — applications will try IPv6 first " +
|
||||||
|
"and wait for it to fail."
|
||||||
|
},
|
||||||
|
evidenceRefs = linkEvidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (sh in shapes.filter { it.routeWithoutAddress }) {
|
||||||
|
val where = if (sh.iface.isBlank()) "This network" else sh.iface
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.V6_ROUTE_WITHOUT_ADDRESS.code,
|
||||||
|
category = FindingRegistry.V6_ROUTE_WITHOUT_ADDRESS.category,
|
||||||
|
severity = Severity.MEDIUM,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "IPv6 router advertised, but no address was configured ($where)",
|
||||||
|
description = "$where has an IPv6 default route but no global IPv6 address. " +
|
||||||
|
"The router is advertising itself as an IPv6 gateway while SLAAC produced " +
|
||||||
|
"no usable address — a missing prefix option, a prefix without the " +
|
||||||
|
"autonomous flag, or DHCPv6-only addressing that did not complete. Hosts " +
|
||||||
|
"believe IPv6 is available and pay a connection timeout on every " +
|
||||||
|
"dual-stack destination before falling back to IPv4, which is felt as " +
|
||||||
|
"general slowness with no packet loss to explain it.",
|
||||||
|
evidenceRefs = linkEvidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = FindingRegistry.CAPTIVE_PORTAL.code,
|
||||||
|
category = FindingRegistry.CAPTIVE_PORTAL.category,
|
||||||
|
severity = FindingRegistry.CAPTIVE_PORTAL.severity, 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 = FindingRegistry.NO_INTERNET.code,
|
||||||
|
category = FindingRegistry.NO_INTERNET.category,
|
||||||
|
severity = FindingRegistry.NO_INTERNET.severity, 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 = FindingRegistry.DNS_ANSWER_REWRITTEN.code,
|
||||||
|
category = FindingRegistry.DNS_ANSWER_REWRITTEN.category,
|
||||||
|
severity = FindingRegistry.DNS_ANSWER_REWRITTEN.severity, 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 = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.code,
|
||||||
|
category = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.category,
|
||||||
|
severity = FindingRegistry.DNS_AUTHORITATIVE_UNREACHABLE.severity, 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 = FindingRegistry.NAT_SYMMETRIC.code,
|
||||||
|
category = FindingRegistry.NAT_SYMMETRIC.category,
|
||||||
|
severity = FindingRegistry.NAT_SYMMETRIC.severity, 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.DNS_RESOLVER && t.status == TestStatus.OK) {
|
||||||
|
// One finding per network: on a phone the wifi resolver can be wedged while
|
||||||
|
// cellular is fine, and "DNS is broken" would be wrong about half the device.
|
||||||
|
val ev = t.evidence
|
||||||
|
if (ev != null) {
|
||||||
|
for ((_, v) in ev) {
|
||||||
|
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
|
fun str(k: String) =
|
||||||
|
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||||
|
val verdict = str("verdict")
|
||||||
|
val ref0 = str("network_ref")
|
||||||
|
val iface0 = networks.firstOrNull { it.id == ref0 }?.iface
|
||||||
|
?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
if (verdict == "search domain swallows queries") {
|
||||||
|
// Severity follows the harm, not the shape: the same misconfiguration
|
||||||
|
// is fatal on a resolver that tries the search form and invisible on
|
||||||
|
// one that does not, and saying "high" for a network that currently
|
||||||
|
// resolves fine would be crying wolf.
|
||||||
|
val breaking = str("system_resolves") != "true"
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.DNS_SEARCH_DOMAIN_UNANSWERED.code,
|
||||||
|
category = FindingRegistry.DNS_SEARCH_DOMAIN_UNANSWERED.category,
|
||||||
|
severity = if (breaking) Severity.HIGH else Severity.MEDIUM,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "The network's search domain swallows DNS queries ($iface0)",
|
||||||
|
description = "This network hands out " +
|
||||||
|
"${str("search_domains") ?: "a search domain"} as a DNS " +
|
||||||
|
"search domain, but its server never answers queries under " +
|
||||||
|
"it — not even to say the name does not exist. Resolvers " +
|
||||||
|
"append that domain to lookups, so they wait for a reply " +
|
||||||
|
"that never comes. " +
|
||||||
|
(if (breaking) {
|
||||||
|
"That is why names are not resolving on this device."
|
||||||
|
} else {
|
||||||
|
"Name resolution still works here, because this " +
|
||||||
|
"resolver tries the plain name first — another " +
|
||||||
|
"device on the same network may fail outright."
|
||||||
|
}) +
|
||||||
|
" Fix it on the router: either stop advertising the search " +
|
||||||
|
"domain, or make the server answer for it, including " +
|
||||||
|
"NXDOMAIN for names it does not have. Note that .local is " +
|
||||||
|
"reserved for mDNS (RFC 6762) and is widely dropped by " +
|
||||||
|
"design; home.arpa (RFC 8375) is the name reserved for this.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (verdict != "server answers, device resolver does not") continue
|
||||||
|
val ref = str("network_ref")
|
||||||
|
val where = networks.firstOrNull { it.id == ref }?.iface
|
||||||
|
?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.code,
|
||||||
|
category = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.category,
|
||||||
|
severity = FindingRegistry.DNS_SYSTEM_RESOLVER_BROKEN.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "This device cannot resolve names, but the DNS server is fine ($where)",
|
||||||
|
description = "A DNS query sent straight from this device was " +
|
||||||
|
"answered by ${str("servers") ?: "the configured server"} with " +
|
||||||
|
"a valid result, yet asking Android to resolve the same name " +
|
||||||
|
"fails. Whatever is wrong sits between this device's resolver " +
|
||||||
|
"and a server that demonstrably works. " +
|
||||||
|
"Turning wifi off and on, or rejoining the network, clears the " +
|
||||||
|
"common case. If it survives a restart it is not a stuck " +
|
||||||
|
"resolver: look for something on this device that filters DNS " +
|
||||||
|
"— an ad blocker, a private-DNS or VPN app — or a per-device " +
|
||||||
|
"rule on the router aimed at this client.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.ICMP_PING6) {
|
||||||
|
// 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 the network claims to provide (a
|
||||||
|
// global address or a default route from RA/DHCPv6) that does not work: that causes
|
||||||
|
// Happy-Eyeballs delays, timeouts and hangs.
|
||||||
|
//
|
||||||
|
// Judged per network, from the per-network evidence rather than the aggregate
|
||||||
|
// status. The aggregate can only say "some network answered", and on a phone with
|
||||||
|
// wifi and cellular up at once that is how "IPv6 is configured but gets no reply"
|
||||||
|
// ends up describing a network where IPv6 was never configured in the first place.
|
||||||
|
val results = icmpResults(t)
|
||||||
|
// The corroborating witness: did a real TCP connection over IPv6 work on this
|
||||||
|
// network? Same evidence shape as the ICMP probe, so the same parser reads it.
|
||||||
|
val v6ConnTest = tests.firstOrNull { it.type == TestType.V6_BROKENNESS }
|
||||||
|
val v6Conn = v6ConnTest?.let { icmpResults(it) } ?: emptyMap()
|
||||||
|
var anyV6Network = false
|
||||||
|
for (n in networks) {
|
||||||
|
val provisioned = ipv6Provisioned(networks, n.id)
|
||||||
|
if (provisioned) anyV6Network = true
|
||||||
|
val r = results[n.id] ?: continue
|
||||||
|
// Silence is only evidence if something was actually sent. A bind that failed
|
||||||
|
// with EPERM says the app could not use the interface, which is a fact about
|
||||||
|
// this app's permissions and says nothing whatsoever about the network.
|
||||||
|
if (!provisioned || !r.attempted || r.ok) continue
|
||||||
|
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
val conn = v6Conn[n.id]
|
||||||
|
val evidence = listOfNotNull(
|
||||||
|
EvidenceRef(t.id), v6ConnTest?.let { EvidenceRef(it.id) },
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
// TCP over IPv6 worked: the silence is filtering, and can be said so.
|
||||||
|
conn?.ok == true -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||||
|
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||||
|
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "ICMPv6 is filtered here — IPv6 itself works ($where)",
|
||||||
|
description = "$where answered a real TCP connection over IPv6, " +
|
||||||
|
"so IPv6 works — but ICMPv6 echo got no reply, so something " +
|
||||||
|
"on this network filters ICMPv6. That is a fault in its own " +
|
||||||
|
"right even though connections succeed: Path MTU Discovery " +
|
||||||
|
"depends on ICMPv6, so large packets can vanish rather than " +
|
||||||
|
"being reported as too big.",
|
||||||
|
evidenceRefs = evidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Both transports failed on a network that advertises IPv6: broken, and
|
||||||
|
// now with the evidence the original v6.broken never had.
|
||||||
|
conn != null && conn.attempted -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
|
||||||
|
category = FindingRegistry.V6_BROKEN.category,
|
||||||
|
severity = FindingRegistry.V6_BROKEN.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "IPv6 is advertised but does not work ($where)",
|
||||||
|
description = "$where advertises IPv6 (a global address and/or a " +
|
||||||
|
"default route), but neither ICMPv6 echo nor a TCP connection " +
|
||||||
|
"over IPv6 got through — two independent transports, both " +
|
||||||
|
"silent. Applications will try IPv6 first and wait out a " +
|
||||||
|
"timeout on every dual-stack destination before falling back " +
|
||||||
|
"to IPv4, felt as everything being slow with no loss to " +
|
||||||
|
"explain it. The network is announcing a service it does not " +
|
||||||
|
"deliver; the fix belongs on the router or upstream.",
|
||||||
|
evidenceRefs = evidence,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// No corroboration available (no server configured, or the connect never
|
||||||
|
// got as far as sending): the honest two-explanation reading stands.
|
||||||
|
else -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = FindingRegistry.V6_NO_ICMP_REPLY.code,
|
||||||
|
category = FindingRegistry.V6_NO_ICMP_REPLY.category,
|
||||||
|
severity = FindingRegistry.V6_NO_ICMP_REPLY.severity,
|
||||||
|
confidence = Confidence.MEDIUM,
|
||||||
|
title = "IPv6 is configured, but ICMPv6 gets no reply ($where)",
|
||||||
|
description = "$where advertises IPv6 (a global address and/or a " +
|
||||||
|
"default route), but ICMPv6 echo got no reply over it. That has " +
|
||||||
|
"two explanations which look identical from here: IPv6 is broken, " +
|
||||||
|
"or ICMPv6 is filtered while IPv6 itself works. Filtering is " +
|
||||||
|
"common and is a fault in its own right — it breaks Path MTU " +
|
||||||
|
"Discovery, so large packets vanish rather than being reported as " +
|
||||||
|
"too big.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyV6Network) {
|
||||||
|
// Said once for the device, not once per interface: "this network is IPv4-only"
|
||||||
|
// repeated per interface reads as several problems instead of one observation.
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
||||||
|
category = FindingRegistry.V6_NOT_OFFERED.category,
|
||||||
|
severity = FindingRegistry.V6_NOT_OFFERED.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "IPv4-only network (no IPv6 offered)",
|
||||||
|
description = "No IPv6 address or default route was provisioned on " +
|
||||||
|
"any active network, 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** Duration-learning key for the Shizuku step, which is three tests but one battery. */
|
||||||
|
const val SHIZUKU_DURATION_KEY = "shizuku.battery"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
// 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- run-duration learning ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Learned duration of one test type on THIS device, or null before the first run.
|
||||||
|
*
|
||||||
|
* The static Probe.estimatedMs values are only cold-start seeds: real durations depend on
|
||||||
|
* the phone and the network it stands in (ICMPv6 answers in milliseconds where IPv6 works
|
||||||
|
* and waits out full timeouts where it does not), so a fixed table is wrong for almost
|
||||||
|
* everyone almost always. What was measured last time is the only estimate that tracks
|
||||||
|
* reality.
|
||||||
|
*/
|
||||||
|
fun learnedDurationMs(type: String): Long? =
|
||||||
|
prefs.getLong("$DURATION_PREFIX$type", -1L).takeIf { it > 0 }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds one measured duration into the estimate — EMA, 70 % old / 30 % new. Heavy enough
|
||||||
|
* on history that a single odd run (a captive portal stalling DNS) does not whipsaw the
|
||||||
|
* bar, light enough that a real change (enrolling with a server un-skips three probes)
|
||||||
|
* converges within a few runs. Recorded whatever the test's status: a probe that skips in
|
||||||
|
* 2 ms will keep skipping in 2 ms until circumstances change, and then the EMA follows.
|
||||||
|
*/
|
||||||
|
fun recordDurationMs(type: String, ms: Long) {
|
||||||
|
if (ms < 0) return
|
||||||
|
val key = "$DURATION_PREFIX$type"
|
||||||
|
val old = prefs.getLong(key, -1L)
|
||||||
|
val next = if (old <= 0) ms else (old * 7 + ms * 3) / 10
|
||||||
|
prefs.edit().putLong(key, next).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address the operator handed out, for showing to a person.
|
||||||
|
*
|
||||||
|
* Separate from [serverUrl], which is the endpoint actually dialled. They differ when the
|
||||||
|
* server publishes one public name and points devices at another to select its pinned
|
||||||
|
* certificate — a detail worth keeping out of the user's face but not out of the settings.
|
||||||
|
*/
|
||||||
|
var serverPublicUrl: String
|
||||||
|
get() = (prefs.getString(SERVER_PUBLIC_URL, "") ?: "").ifBlank { serverUrl }
|
||||||
|
set(v) = prefs.edit().putString(SERVER_PUBLIC_URL, v.trim()).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the server said about itself, last time it was asked: addresses, ports, capabilities.
|
||||||
|
*
|
||||||
|
* Cached as a rendered block rather than as fields, because it is shown and never acted on —
|
||||||
|
* these are facts to read, not settings to apply, and storing them as settings would invite
|
||||||
|
* exactly the confusion of an editable box that changes nothing.
|
||||||
|
*/
|
||||||
|
var serverFacts: String
|
||||||
|
get() = prefs.getString(SERVER_FACTS, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(SERVER_FACTS, v).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's own addresses, learned from its profile, for reaching it when DNS will not.
|
||||||
|
*
|
||||||
|
* Only the primaries: the alternate pair exists for NAT behaviour discovery and does not carry
|
||||||
|
* the control plane, so falling back to one would fail for a second, unrelated reason.
|
||||||
|
*/
|
||||||
|
var serverAddrs: String
|
||||||
|
get() = prefs.getString(SERVER_ADDRS, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(SERVER_ADDRS, v).apply()
|
||||||
|
|
||||||
|
fun serverAddrList(): List<String> =
|
||||||
|
serverAddrs.split(',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The DNS zone this server is authoritative for, learned from its profile.
|
||||||
|
*
|
||||||
|
* Cached because the canary probe runs at device tier, before anything has talked to the
|
||||||
|
* server, and a probe that had to make a control-plane call first would fail on exactly the
|
||||||
|
* networks worth measuring. Empty means "not known yet", and the probe reports itself as
|
||||||
|
* skipped rather than inventing a zone.
|
||||||
|
*/
|
||||||
|
var canaryZone: String
|
||||||
|
get() = prefs.getString(CANARY_ZONE, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(CANARY_ZONE, v.trim()).apply()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host part of the configured server URL, for probes that address it directly (STUN).
|
||||||
|
*
|
||||||
|
* Derived rather than stored: a second copy of the server's name is a second thing to keep in
|
||||||
|
* step, and it would go stale the moment someone re-enrolled against a different server.
|
||||||
|
*/
|
||||||
|
fun serverHost(): String = runCatching {
|
||||||
|
java.net.URI(serverUrl).host?.takeIf { it.isNotBlank() }
|
||||||
|
}.getOrNull() ?: ""
|
||||||
|
|
||||||
|
// ---- account ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PKCE verifier and state for a sign-in that is out at the browser.
|
||||||
|
*
|
||||||
|
* Persisted rather than held in memory because handing control to a browser backgrounds this
|
||||||
|
* process, and Android may kill it before the callback returns. An in-memory value works on a
|
||||||
|
* developer's device and fails on a phone under memory pressure.
|
||||||
|
*/
|
||||||
|
var pendingVerifier: String
|
||||||
|
get() = prefs.getString(PENDING_VERIFIER, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(PENDING_VERIFIER, v).apply()
|
||||||
|
|
||||||
|
var pendingState: String
|
||||||
|
get() = prefs.getString(PENDING_STATE, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(PENDING_STATE, v).apply()
|
||||||
|
|
||||||
|
fun clearPendingAuth() = prefs.edit().remove(PENDING_VERIFIER).remove(PENDING_STATE).apply()
|
||||||
|
|
||||||
|
/** Display name of whoever is signed in on this device; empty when nobody is. */
|
||||||
|
var accountName: String
|
||||||
|
get() = prefs.getString(ACCOUNT_NAME, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(ACCOUNT_NAME, v).apply()
|
||||||
|
|
||||||
|
var accountId: String
|
||||||
|
get() = prefs.getString(ACCOUNT_ID, "") ?: ""
|
||||||
|
set(v) = prefs.edit().putString(ACCOUNT_ID, v).apply()
|
||||||
|
|
||||||
|
val signedIn: Boolean get() = accountName.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"
|
||||||
|
const val SERVER_PUBLIC_URL = "server_public_url"
|
||||||
|
const val SERVER_FACTS = "server_facts"
|
||||||
|
const val SERVER_ADDRS = "server_addrs"
|
||||||
|
const val CANARY_ZONE = "server_canary_zone"
|
||||||
|
const val PENDING_VERIFIER = "pending_auth_verifier"
|
||||||
|
const val PENDING_STATE = "pending_auth_state"
|
||||||
|
const val ACCOUNT_NAME = "account_name"
|
||||||
|
const val ACCOUNT_ID = "account_id"
|
||||||
|
const val DURATION_PREFIX = "duration_ms."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
// 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.safeDrawingPadding
|
||||||
|
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.width
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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 androidx.compose.ui.unit.sp
|
||||||
|
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,
|
||||||
|
accountName: String,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onSignOut: () -> Unit,
|
||||||
|
onEnroll: (String) -> Unit,
|
||||||
|
serverStatus: String?,
|
||||||
|
enrollStatus: 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("") }
|
||||||
|
// The public name, which is what the operator handed out and what a person recognises. The
|
||||||
|
// endpoint actually dialled is shown beneath it when the two differ, rather than hidden — a
|
||||||
|
// network engineer debugging a connection wants to see where it really goes.
|
||||||
|
var serverUrl by remember { mutableStateOf(settings.serverPublicUrl) }
|
||||||
|
var serverPin by remember { mutableStateOf(settings.serverPin) }
|
||||||
|
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
||||||
|
// Enrolling is asynchronous, so these are re-read when its result lands rather than when the
|
||||||
|
// button is pressed — reading them immediately showed the previous server's values and looked
|
||||||
|
// exactly like an enrollment that had silently done nothing.
|
||||||
|
var serverFacts by remember { mutableStateOf(settings.serverFacts) }
|
||||||
|
androidx.compose.runtime.LaunchedEffect(enrollStatus, serverStatus) {
|
||||||
|
serverFacts = settings.serverFacts
|
||||||
|
serverUrl = settings.serverPublicUrl
|
||||||
|
serverPin = settings.serverPin
|
||||||
|
serverCred = settings.serverCredential
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().safeDrawingPadding().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)
|
||||||
|
|
||||||
|
// At FULL nothing is pseudonymized, so a salt has nothing to act on. Shown
|
||||||
|
// disabled rather than hidden: the setting is still stored and still applies the
|
||||||
|
// moment the level changes, and a control that vanishes hides that fact.
|
||||||
|
Toggle(
|
||||||
|
label = "Stable pseudonyms across runs",
|
||||||
|
detail = if (privacy == PrivacyLevel.FULL) {
|
||||||
|
"Not used at this level — nothing is pseudonymized, so there is nothing " +
|
||||||
|
"to keep stable. Choose balanced or strict to use this."
|
||||||
|
} else {
|
||||||
|
"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 && privacy != PrivacyLevel.FULL,
|
||||||
|
enabled = privacy != PrivacyLevel.FULL,
|
||||||
|
) { stableSalt = it; settings.stableSalt = it }
|
||||||
|
|
||||||
|
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- account ----
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Account", style = MaterialTheme.typography.titleMedium)
|
||||||
|
if (accountName.isNotBlank()) {
|
||||||
|
Text("Signed in as $accountName", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(
|
||||||
|
"Runs from every device signed in to this account share one history.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
TextButton(onClick = onSignOut) { Text("Sign out") }
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
"Signing in is optional. It links this device to an account on your " +
|
||||||
|
"server, so several devices share one history — and some servers only " +
|
||||||
|
"accept uploads from a signed-in device.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Button(onClick = onSignIn, enabled = settings.serverConfigured) {
|
||||||
|
Text("Sign in")
|
||||||
|
}
|
||||||
|
if (!settings.serverConfigured) {
|
||||||
|
Text(
|
||||||
|
"Enrol with a server first — the account belongs to the server, not " +
|
||||||
|
"to the app.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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
|
||||||
|
},
|
||||||
|
enabled = enrollLink.isNotBlank(),
|
||||||
|
) { Text("Enroll") }
|
||||||
|
// Beside the button that caused it. Enrolling is asynchronous, so without this the
|
||||||
|
// only sign of success is three fields quietly changing further down the card.
|
||||||
|
enrollStatus?.let {
|
||||||
|
Text(it, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = serverUrl,
|
||||||
|
onValueChange = {
|
||||||
|
serverUrl = it
|
||||||
|
// Typed by hand there is no discovery to consult, so what was entered is
|
||||||
|
// both the public name and the endpoint. Setting only one of them would
|
||||||
|
// leave the app dialling the previous server.
|
||||||
|
settings.serverUrl = it
|
||||||
|
settings.serverPublicUrl = it
|
||||||
|
},
|
||||||
|
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
// Directly under the field it explains. Anywhere else it reads as a stray sentence
|
||||||
|
// about some other part of the screen.
|
||||||
|
if (settings.serverUrl.isNotBlank() && settings.serverUrl != settings.serverPublicUrl) {
|
||||||
|
Text(
|
||||||
|
"Connects to ${settings.serverUrl} — this server publishes one name and " +
|
||||||
|
"points devices at another, so its pinned certificate can share a port " +
|
||||||
|
"with its web interface.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// What the server reported, placed under the button that asks it rather than among
|
||||||
|
// the fields above: these are facts to read, not settings to apply, and an
|
||||||
|
// editable-looking box that changes nothing is worse than no box at all.
|
||||||
|
//
|
||||||
|
// Monospaced so the addresses line up under each other — column alignment is most
|
||||||
|
// of what makes a list of IPs quicker to read than prose.
|
||||||
|
if (serverFacts.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"WHAT THIS SERVER REPORTS",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
// Real columns rather than padded text: the label column has a fixed
|
||||||
|
// width, so values line up whatever the font does, and a long value
|
||||||
|
// wraps inside its own column instead of under the labels.
|
||||||
|
for (line in serverFacts.lines()) {
|
||||||
|
val label = line.substringBefore('|')
|
||||||
|
val value = line.substringAfter('|', "")
|
||||||
|
Row(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.width(72.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
// Dimmed together with the switch, so "this does nothing right now" reads at a glance
|
||||||
|
// instead of only on close inspection.
|
||||||
|
val alpha = if (enabled) 1f else 0.5f
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium, color = LocalContentColor.current.copy(alpha = alpha))
|
||||||
|
Text(detail, style = MaterialTheme.typography.bodySmall, color = LocalContentColor.current.copy(alpha = alpha))
|
||||||
|
}
|
||||||
|
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,225 @@
|
|||||||
|
// 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,
|
||||||
|
/**
|
||||||
|
* How the *archived* document is redacted. Always "full" in practice, because the archive
|
||||||
|
* deliberately keeps the unredacted run - see the package doc. This is not what was uploaded.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
/**
|
||||||
|
* The level the run was *uploaded* at, which is a different document from the archived one.
|
||||||
|
*
|
||||||
|
* Kept separately because conflating the two is actively misleading: the history row showed
|
||||||
|
* the archive's own level ("full") directly beneath "uploaded to fmr", which reads as "the
|
||||||
|
* complete data was uploaded" when a redacted copy had been sent. A privacy display that
|
||||||
|
* overstates what left the device is worse than none.
|
||||||
|
*/
|
||||||
|
@SerialName("uploaded_as") val uploadedAs: 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, uploadedAs: String? = null) {
|
||||||
|
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, uploadedAs = uploadedAs),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
// The schema calls it `overall` (Summary.overall); reading `verdict` here silently
|
||||||
|
// yielded null for every run, so the history list's most prominent element - the
|
||||||
|
// coloured verdict - was blank on every row.
|
||||||
|
verdict = doc["summary"]?.jsonObject?.get("overall")?.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,197 @@
|
|||||||
|
// 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":{"overall":"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", "balanced")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive's own level and the level a run was uploaded at describe *different documents*.
|
||||||
|
// Showing the archive's ("full", because the archive is deliberately unredacted) next to
|
||||||
|
// "uploaded to fmr" reads as "the complete data was uploaded" when a redacted copy was sent —
|
||||||
|
// a privacy display that overstates what left the device is worse than none.
|
||||||
|
@Test
|
||||||
|
fun theUploadedLevelIsRecordedSeparatelyFromTheArchivedOne() {
|
||||||
|
val a = archive()
|
||||||
|
// A real archived document carries no privacy stamp: the anonymizer never runs on the
|
||||||
|
// archive. The shared doc() fixture has one, which is exactly the unrealism that let this
|
||||||
|
// confusion through in the first place.
|
||||||
|
a.save("""{"run":{"id":"run-1"},"findings":[],"summary":{"overall":"green"}}""")
|
||||||
|
a.markUploaded("run-1", "fmr", "balanced")
|
||||||
|
val meta = a.list().single()
|
||||||
|
assertEquals("full", meta.anonymization, "the archived copy is unredacted, by design")
|
||||||
|
assertEquals("balanced", meta.uploadedAs, "the uploaded copy was redacted, and must say so")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verdict is read from `summary.overall` — the schema's actual field name. Reading
|
||||||
|
// `summary.verdict` silently yielded null for every run, so the history list's most prominent
|
||||||
|
// element was blank on every row while everything else looked fine.
|
||||||
|
@Test
|
||||||
|
fun theVerdictComesFromTheSchemasOverallField() {
|
||||||
|
val a = archive()
|
||||||
|
a.save("""{"run":{"id":"r1"},"findings":[],"summary":{"overall":"yellow"}}""")
|
||||||
|
assertEquals("yellow", a.list().single().verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,107 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a round-trip train into its two directions using what the server witnessed.
|
||||||
|
*
|
||||||
|
* A round trip can only report that *something* was lost somewhere. That is the least useful form
|
||||||
|
* of the answer: "3 % loss" sends an engineer looking in both directions at once. The server
|
||||||
|
* records every packet it received, per sequence number (probe-protocol.md §6), so the two cases
|
||||||
|
* are actually distinguishable:
|
||||||
|
*
|
||||||
|
* - sent, never seen by the server → **upstream** loss
|
||||||
|
* - seen by the server, reply never arrived → **downstream** loss
|
||||||
|
*
|
||||||
|
* The same records give one-way delay *variation* per direction. Absolute one-way delay would
|
||||||
|
* need synchronised clocks and we deliberately have none (measurement-schema.md's two-clock rule),
|
||||||
|
* but the variation does not: (server_rx − client_tx) contains an unknown constant clock offset,
|
||||||
|
* and differencing successive samples cancels it. So jitter is honestly attributable to a
|
||||||
|
* direction even though latency is not.
|
||||||
|
*/
|
||||||
|
object Directional {
|
||||||
|
|
||||||
|
/** One probe as the client saw it. [tRxNs] null means no reply came back. */
|
||||||
|
data class Sample(val seq: Int, val tTxNs: Long, val tRxNs: Long?)
|
||||||
|
|
||||||
|
/** One probe as the server saw it: its own receive and transmit stamps, on its own clock. */
|
||||||
|
data class ServerSighting(val seq: Int, val tRxNs: Long, val tTxNs: Long)
|
||||||
|
|
||||||
|
fun analyse(sent: List<Sample>, seen: List<ServerSighting>): DirectionalMetrics {
|
||||||
|
val byServerSeq = seen.associateBy { it.seq }
|
||||||
|
// Only sequences we actually sent count. A server record for a sequence we have no note
|
||||||
|
// of is not evidence about this train — it is a bug or a stray, and silently folding it
|
||||||
|
// in would produce loss percentages above 100 or below zero.
|
||||||
|
val relevant = sent.filter { byServerSeq.containsKey(it.seq) }
|
||||||
|
|
||||||
|
val nSent = sent.size
|
||||||
|
val nSeen = relevant.size
|
||||||
|
val nReplied = sent.count { it.tRxNs != null }
|
||||||
|
|
||||||
|
// A reply can only exist if the request arrived, so downstream loss is measured against
|
||||||
|
// what the server saw, not against what we sent — otherwise upstream loss is counted twice.
|
||||||
|
val lostUp = nSent - nSeen
|
||||||
|
val lostDown = (nSeen - nReplied).coerceAtLeast(0)
|
||||||
|
|
||||||
|
val upDeltas = relevant.sortedBy { it.seq }
|
||||||
|
.map { byServerSeq.getValue(it.seq).tRxNs - it.tTxNs }
|
||||||
|
val downDeltas = sent.filter { it.tRxNs != null && byServerSeq.containsKey(it.seq) }
|
||||||
|
.sortedBy { it.seq }
|
||||||
|
.map { it.tRxNs!! - byServerSeq.getValue(it.seq).tTxNs }
|
||||||
|
|
||||||
|
return DirectionalMetrics(
|
||||||
|
sent = nSent,
|
||||||
|
seenByServer = nSeen,
|
||||||
|
repliesReceived = nReplied,
|
||||||
|
lostUpstream = lostUp,
|
||||||
|
lostDownstream = lostDown,
|
||||||
|
lossUpstreamPct = pct(lostUp, nSent),
|
||||||
|
// Denominator is what reached the server: of the packets that got there, how many
|
||||||
|
// replies came back.
|
||||||
|
lossDownstreamPct = pct(lostDown, nSeen),
|
||||||
|
jitterUpstreamMs = jitterMs(upDeltas),
|
||||||
|
jitterDownstreamMs = jitterMs(downDeltas),
|
||||||
|
/** True when the server saw nothing at all, which is a different fault from loss. */
|
||||||
|
noneReachedServer = nSent > 0 && nSeen == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mean absolute difference between consecutive one-way samples (RFC 3393 IPDV, averaged).
|
||||||
|
*
|
||||||
|
* Differencing is what makes this legitimate without synchronised clocks: each sample carries
|
||||||
|
* the same unknown offset between the two clocks, and the difference cancels it. Fewer than
|
||||||
|
* two samples yields null rather than zero — "no jitter" and "not enough data to say" are
|
||||||
|
* different claims and only one of them is true here.
|
||||||
|
*/
|
||||||
|
private fun jitterMs(oneWayNs: List<Long>): Double? {
|
||||||
|
if (oneWayNs.size < 2) return null
|
||||||
|
val deltas = oneWayNs.zipWithNext { a, b -> kotlin.math.abs(b - a) }
|
||||||
|
return round2(deltas.average() / 1_000_000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pct(part: Int, whole: Int): Double =
|
||||||
|
if (whole <= 0) 0.0 else round2(part * 100.0 / whole)
|
||||||
|
|
||||||
|
private fun round2(v: Double) = Math.round(v * 100.0) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Directional metrics for train.udp_updown; recomputable from the columnar evidence. */
|
||||||
|
@Serializable
|
||||||
|
data class DirectionalMetrics(
|
||||||
|
val sent: Int,
|
||||||
|
@SerialName("seen_by_server") val seenByServer: Int,
|
||||||
|
@SerialName("replies_received") val repliesReceived: Int,
|
||||||
|
@SerialName("lost_upstream") val lostUpstream: Int,
|
||||||
|
@SerialName("lost_downstream") val lostDownstream: Int,
|
||||||
|
@SerialName("loss_upstream_pct") val lossUpstreamPct: Double,
|
||||||
|
@SerialName("loss_downstream_pct") val lossDownstreamPct: Double,
|
||||||
|
/** One-way delay variation (RFC 3393), per direction. Null when there were too few samples. */
|
||||||
|
@SerialName("jitter_upstream_ms") val jitterUpstreamMs: Double? = null,
|
||||||
|
@SerialName("jitter_downstream_ms") val jitterDownstreamMs: Double? = null,
|
||||||
|
@SerialName("none_reached_server") val noneReachedServer: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shorter, but long enough to cover the first_last mode's deliberate 250 ms hold plus a
|
||||||
|
* reassembly. A fragment burst is one datagram: it is here quickly or not at all.
|
||||||
|
*/
|
||||||
|
private val fragWindowMs = 1_500L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the server to send one deliberately-fragmented datagram per ordering, and reports
|
||||||
|
* which orderings survive the path.
|
||||||
|
*
|
||||||
|
* Kernel fragmentation always emits fragments in order, first one first, so an oversized
|
||||||
|
* datagram can only answer "do fragments get through at all". The interesting fault is about
|
||||||
|
* ordering: only the *first* fragment carries the UDP ports, so a stateful firewall that has
|
||||||
|
* not seen it has nothing to match the rest against, and many drop them. That failure is
|
||||||
|
* invisible to every in-order test and shows up in the field as "large DNS answers fail here"
|
||||||
|
* or "the tunnel breaks when the MTU drops".
|
||||||
|
*/
|
||||||
|
fun fragmentOrdering(
|
||||||
|
credential: String,
|
||||||
|
sessionId: String,
|
||||||
|
control: ControlClient,
|
||||||
|
probe: ProbeSession,
|
||||||
|
sessionRef: String,
|
||||||
|
sizeBytes: Int = 2000,
|
||||||
|
fragBytes: Int = 576,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
val delivered = LinkedHashMap<String, Boolean>()
|
||||||
|
val fragmentCounts = LinkedHashMap<String, Int>()
|
||||||
|
var unsupported = false
|
||||||
|
|
||||||
|
for (mode in FRAG_MODES) {
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(
|
||||||
|
credential, sessionId,
|
||||||
|
"""{"action":"frag_send","size_bytes":$sizeBytes,"mode":"$mode","frag_bytes":$fragBytes}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reply.isFailure) {
|
||||||
|
// A server without a raw socket says so; that is a missing capability, not a
|
||||||
|
// property of the network, and must not be recorded as a failed delivery.
|
||||||
|
unsupported = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parseInt(reply.getOrNull(), "fragments")?.let { fragmentCounts[mode] = it }
|
||||||
|
// The burst is already on the wire when the action returns (it is sent
|
||||||
|
// synchronously), so anything that survived is either here or lost.
|
||||||
|
val got = probe.collectGranted(fragWindowMs).any { it.type == Wire.TYPE_FRAG_DATA }
|
||||||
|
delivered[mode] = got
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unsupported) {
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.UNSUPPORTED,
|
||||||
|
error = TestError("no_raw_socket", "this server cannot craft fragments"),
|
||||||
|
) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
FragOrderingMetrics(
|
||||||
|
sizeBytes = sizeBytes,
|
||||||
|
fragBytes = fragBytes,
|
||||||
|
fragmentsPerBurst = fragmentCounts,
|
||||||
|
deliveredByMode = delivered,
|
||||||
|
inOrderDelivered = delivered[FRAG_IN_ORDER] == true,
|
||||||
|
reorderedDelivered = delivered[FRAG_REVERSED] == true,
|
||||||
|
delayedFirstDelivered = delivered[FRAG_FIRST_LAST] == true,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
val inOrder = delivered[FRAG_IN_ORDER] == true
|
||||||
|
val reversed = delivered[FRAG_REVERSED] == true
|
||||||
|
val firstLast = delivered[FRAG_FIRST_LAST] == true
|
||||||
|
|
||||||
|
if (!inOrder) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.FRAGMENTS_BLOCKED, testId,
|
||||||
|
"IP fragments do not reach this device",
|
||||||
|
"A fragmented datagram sent in the normal order never arrived. Anything that " +
|
||||||
|
"relies on fragmentation — large DNS answers over UDP, some VPN traffic — " +
|
||||||
|
"will fail here rather than slow down.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (!reversed || !firstLast) {
|
||||||
|
// The precise and useful finding: fragments work, but only if they arrive tidily.
|
||||||
|
val which = buildList {
|
||||||
|
if (!reversed) add("out of order")
|
||||||
|
if (!firstLast) add("with the first fragment delayed")
|
||||||
|
}.joinToString(" or ")
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.FRAGMENT_REORDER_SENSITIVE, testId,
|
||||||
|
"Fragments are dropped when they arrive $which",
|
||||||
|
"In-order fragments are delivered, but the same datagram sent $which is not. " +
|
||||||
|
"Something on the path only reassembles when the first fragment (the one " +
|
||||||
|
"carrying the UDP ports) arrives first — typical of a stateful firewall " +
|
||||||
|
"or NAT. It works until the network reorders, then fails intermittently, " +
|
||||||
|
"which is the hardest kind of fault to chase.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = if (inOrder) TestStatus.OK else TestStatus.PARTIAL,
|
||||||
|
metrics = metrics,
|
||||||
|
) to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
// DSCP to mark the train with (0-63), or -1 to leave packets unmarked. Pairing a marked
|
||||||
|
// downtrain with the server-observed DSCP of an upstream train is the two-direction
|
||||||
|
// sec.dscp_ecn_survival measurement.
|
||||||
|
trainDscp: Int = -1,
|
||||||
|
): 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, trainDscp,
|
||||||
|
)
|
||||||
|
|
||||||
|
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
|
||||||
|
|
||||||
|
// Fragment ordering only makes sense once we know fragments arrive at all; when they do
|
||||||
|
// not, the ordering variants would all report "not delivered" and read as three faults
|
||||||
|
// instead of one.
|
||||||
|
if (frag.largestDelivered != null) {
|
||||||
|
val (fragTest, fragFindings) =
|
||||||
|
fragmentOrdering(credential, sessionId, control, probe, sessionRef)
|
||||||
|
tests.add(fragTest)
|
||||||
|
findings.addAll(fragFindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
FindingRegistry.MTU_REDUCED_DOWNSTREAM, 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(
|
||||||
|
FindingRegistry.MTU_DOWNSTREAM_BLACKHOLE, 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(
|
||||||
|
FindingRegistry.DOWNSTREAM_BLOCKED, 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(
|
||||||
|
FindingRegistry.LOSS_DOWNSTREAM, 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(
|
||||||
|
FindingRegistry.DOWNSTREAM_REORDER, 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, dscp: Int = -1,
|
||||||
|
): TrainResult {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
|
||||||
|
// dscp is only sent when requested: an older server rejects unknown-value problems
|
||||||
|
// louder than absent keys, and unmarked is the correct default for a plain loss train.
|
||||||
|
val dscpField = if (dscp in 0..63) ""","dscp":$dscp""" else ""
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(
|
||||||
|
credential, sessionId,
|
||||||
|
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,""" +
|
||||||
|
""""interval_us":$intervalUs$dscpField}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
dscpRequested = dscp.takeIf { it in 0..63 },
|
||||||
|
// The server says whether it could actually mark (dscp_applied); recorded so a
|
||||||
|
// survival comparison never blames the path for a marking the sender skipped.
|
||||||
|
dscpApplied = reply.getOrNull()?.let {
|
||||||
|
Regex("\"dscp_applied\"\\s*:\\s*(true|false)").find(it)?.groupValues?.get(1)?.toBoolean()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) 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 ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a finding from a registry entry, which supplies the code, category and severity.
|
||||||
|
*
|
||||||
|
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
|
||||||
|
* compile error, and two call sites cannot disagree about which category a finding belongs
|
||||||
|
* to - a disagreement that would split one fault across two verdict lights.
|
||||||
|
*/
|
||||||
|
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
|
||||||
|
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
|
||||||
|
|
||||||
|
const val FRAG_IN_ORDER = "in_order"
|
||||||
|
const val FRAG_REVERSED = "reversed"
|
||||||
|
const val FRAG_FIRST_LAST = "first_last"
|
||||||
|
val FRAG_MODES = listOf(FRAG_IN_ORDER, FRAG_REVERSED, FRAG_FIRST_LAST)
|
||||||
|
|
||||||
|
/** 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 mtu.frag_ordering. */
|
||||||
|
@Serializable
|
||||||
|
data class FragOrderingMetrics(
|
||||||
|
@SerialName("size_bytes") val sizeBytes: Int,
|
||||||
|
@SerialName("frag_bytes") val fragBytes: Int,
|
||||||
|
@SerialName("fragments_per_burst") val fragmentsPerBurst: Map<String, Int>,
|
||||||
|
@SerialName("delivered_by_mode") val deliveredByMode: Map<String, Boolean>,
|
||||||
|
@SerialName("in_order_delivered") val inOrderDelivered: Boolean,
|
||||||
|
@SerialName("reordered_delivered") val reorderedDelivered: Boolean,
|
||||||
|
@SerialName("delayed_first_delivered") val delayedFirstDelivered: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
@SerialName("dscp_requested") val dscpRequested: Int? = null,
|
||||||
|
@SerialName("dscp_applied") val dscpApplied: Boolean? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
// 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.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
/**
|
||||||
|
* Throughput moves real data — a 5-second run at 50 Mbps is about 30 MB — so it is off
|
||||||
|
* unless asked for. On a metered mobile connection that is the user's money, and a
|
||||||
|
* measurement tool that spends it without being told to is not one people keep installed.
|
||||||
|
*/
|
||||||
|
val throughput: Boolean = false,
|
||||||
|
@Suppress("unused") val throughputSeconds: Int = 5,
|
||||||
|
@Suppress("unused") val throughputKbps: Int = 50_000,
|
||||||
|
)
|
||||||
|
|
||||||
|
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, control, session.sessionId)
|
||||||
|
tests.add(test)
|
||||||
|
allFindings.addAll(findings)
|
||||||
|
|
||||||
|
// The upstream train needs no grant and no capability beyond udp-probe itself; a
|
||||||
|
// server that predates trains simply never answers the report request, which the
|
||||||
|
// measurement reports as exactly that ambiguity rather than as network loss.
|
||||||
|
val (utTest, utFindings) = UpstreamTrainMeasurement(ids)
|
||||||
|
.run(ps, sessionRef = "sess-1")
|
||||||
|
tests.add(utTest)
|
||||||
|
allFindings.addAll(utFindings)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.throughput && profile.supports("throughput")) {
|
||||||
|
val (tpTest, tpFindings) = ThroughputMeasurement(ids).run(
|
||||||
|
cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1",
|
||||||
|
durationS = cfg.throughputSeconds, kbps = cfg.throughputKbps,
|
||||||
|
)
|
||||||
|
tests.add(tpTest)
|
||||||
|
allFindings.addAll(tpFindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
control: ControlClient? = null, sessionId: String? = null,
|
||||||
|
): 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>()
|
||||||
|
|
||||||
|
// Wire sequence numbers, kept so the server's observations can be correlated packet by
|
||||||
|
// packet. They are not 0..n-1: the counter is shared with every other packet type on the
|
||||||
|
// session, so "the nth echo" is not "sequence n".
|
||||||
|
val wireSeqs = ArrayList<Int>()
|
||||||
|
|
||||||
|
for (i in 0 until cfg.echoCount) {
|
||||||
|
val txMono = ids.monoNs() - startMono
|
||||||
|
val r = ps.echo(cfg.echoPaddingBytes)
|
||||||
|
wireSeqs.add(ps.lastSeq)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask the server what it actually received. This is what turns "3 % loss somewhere" into
|
||||||
|
// "3 % loss upstream" - the least useful form of the answer into a usable one.
|
||||||
|
val directional: DirectionalMetrics? =
|
||||||
|
if (control != null && sessionId != null) {
|
||||||
|
runCatching {
|
||||||
|
val samples = wireSeqs.indices.map {
|
||||||
|
Directional.Sample(wireSeqs[it], tTx[it] ?: 0L, tRx[it])
|
||||||
|
}
|
||||||
|
Directional.analyse(samples, serverSightings(control, cfg, sessionId))
|
||||||
|
}.getOrNull() // an older server without the endpoint simply yields no split
|
||||||
|
} else {
|
||||||
|
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 directionalJson = directional?.let {
|
||||||
|
json.encodeToJsonElement(DirectionalMetrics.serializer(), it) as JsonObject
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
).let { base -> JsonObject((base as JsonObject) + (directionalJson ?: JsonObject(emptyMap()))) }
|
||||||
|
|
||||||
|
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(FindingRegistry.UDP_UNREACHABLE, 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(FindingRegistry.UDP_LOSS, testId,
|
||||||
|
"High UDP loss to the server (${round1(lossPct)}%)",
|
||||||
|
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
|
||||||
|
}
|
||||||
|
// Naming the direction is the entire value of the split, so the findings do.
|
||||||
|
directional?.let { d ->
|
||||||
|
when {
|
||||||
|
d.noneReachedServer && received == 0 -> findings.add(
|
||||||
|
finding(FindingRegistry.UDP_UNREACHABLE_UPSTREAM, testId,
|
||||||
|
"Nothing reached the server",
|
||||||
|
"The server received none of the ${d.sent} probes, so the traffic is being " +
|
||||||
|
"dropped on the way out, not on the way back. A firewall or NAT on " +
|
||||||
|
"this side of the path is the place to look."),
|
||||||
|
)
|
||||||
|
d.lossUpstreamPct >= 2.0 -> findings.add(
|
||||||
|
finding(FindingRegistry.LOSS_UPSTREAM, testId,
|
||||||
|
"${d.lossUpstreamPct} % of probes were lost on the way to the server",
|
||||||
|
"${d.lostUpstream} of ${d.sent} probes never reached the server. The " +
|
||||||
|
"return path is not implicated: replies came back for everything that " +
|
||||||
|
"arrived."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (d.lossDownstreamPct >= 2.0) {
|
||||||
|
findings.add(
|
||||||
|
finding(FindingRegistry.LOSS_DOWNSTREAM, testId,
|
||||||
|
"${d.lossDownstreamPct} % of replies were lost on the way back",
|
||||||
|
"The server received ${d.seenByServer} probes and answered them, but " +
|
||||||
|
"${d.lostDownstream} of those replies never arrived. The outbound path " +
|
||||||
|
"is fine; the fault is on the return leg."),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (natRebinding) {
|
||||||
|
findings.add(finding(FindingRegistry.NAT_UDP_REBINDING, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's per-packet record of this session's echoes (spec section 6). Filtered to
|
||||||
|
* ECHO_REQ, because the observation list also holds MTU probes and anything else we sent -
|
||||||
|
* counting those as train packets would invent loss that is not there.
|
||||||
|
*/
|
||||||
|
private fun serverSightings(
|
||||||
|
control: ControlClient, cfg: Config, sessionId: String,
|
||||||
|
): List<Directional.ServerSighting> {
|
||||||
|
val body = control.observations(cfg.credential, sessionId)
|
||||||
|
val packets = Json.parseToJsonElement(body).jsonObject["udp"]
|
||||||
|
?.jsonObject?.get("packets") as? JsonArray ?: return emptyList()
|
||||||
|
return packets.mapNotNull { el ->
|
||||||
|
val o = el as? JsonObject ?: return@mapNotNull null
|
||||||
|
val type = o["type"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
|
||||||
|
if (type != ECHO_REQ_TYPE) return@mapNotNull null
|
||||||
|
Directional.ServerSighting(
|
||||||
|
seq = o["seq"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
|
||||||
|
tRxNs = o["t_rx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
|
||||||
|
tTxNs = o["t_tx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a finding from a registry entry, which supplies the code, category and severity.
|
||||||
|
*
|
||||||
|
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
|
||||||
|
* compile error, and two call sites cannot disagree about which category a finding belongs
|
||||||
|
* to - a disagreement that would split one fault across two verdict lights.
|
||||||
|
*/
|
||||||
|
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val Wire_HEADER = 32
|
||||||
|
const val ECHO_REQ_TYPE = 0x01
|
||||||
|
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,338 @@
|
|||||||
|
// 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
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downstream throughput: the server sends at a paced rate for a bounded time and the client
|
||||||
|
* measures what arrives (`perf.throughput_udp`).
|
||||||
|
*
|
||||||
|
* The number this produces is only meaningful with a qualifier attached, and getting that
|
||||||
|
* qualifier right is most of the work here. A throughput test reports the *smallest* limit on the
|
||||||
|
* path, and the sender's own ceiling is one of the candidates: if the server was asked for 50 Mbps
|
||||||
|
* and 50 Mbps arrived, the network was never the constraint and "50 Mbps" says nothing about it.
|
||||||
|
* Reporting that as a capacity measurement would be a confident lie, so the result always carries
|
||||||
|
* [ThroughputMetrics.limitedBy] and a finding is only raised when the network is actually
|
||||||
|
* implicated.
|
||||||
|
*
|
||||||
|
* Comparing against the *sender's* count rather than the requested rate is the other half: the
|
||||||
|
* server reports how much it actually put on the wire, and the gap between that and what arrived
|
||||||
|
* is the loss. A receiver alone cannot tell "the network dropped it" from "the sender never sent
|
||||||
|
* it", and guessing turns a healthy server-side limit into a phantom network fault.
|
||||||
|
*/
|
||||||
|
class ThroughputMeasurement(private val ids: IdSource) {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
fun run(
|
||||||
|
credential: String,
|
||||||
|
sessionId: String,
|
||||||
|
control: ControlClient,
|
||||||
|
probe: ProbeSession,
|
||||||
|
sessionRef: String,
|
||||||
|
durationS: Int = 5,
|
||||||
|
kbps: Int = 50_000,
|
||||||
|
sizeBytes: Int = 1200,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(
|
||||||
|
credential, sessionId,
|
||||||
|
"""{"action":"throughput","direction":"down","duration_s":$durationS,""" +
|
||||||
|
""""kbps":$kbps,"size_bytes":$sizeBytes}""",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reply.isFailure) {
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.UNSUPPORTED,
|
||||||
|
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "throughput refused"),
|
||||||
|
) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server may have shortened the run to fit its own byte budget; listen for what it
|
||||||
|
// actually promised, not for what we asked.
|
||||||
|
val plannedMs = parseInt(reply.getOrNull(), "duration_ms") ?: (durationS * 1000)
|
||||||
|
|
||||||
|
// A margin past the planned end so the tail of the run is not counted as loss: packets
|
||||||
|
// still in flight when we stop listening were not dropped, they were merely late.
|
||||||
|
val received = probe.collectGranted(plannedMs + 1_500L)
|
||||||
|
.filter { it.type == Wire.TYPE_THROUGHPUT_DATA }
|
||||||
|
|
||||||
|
val bytes = received.sumOf { it.sizeBytes.toLong() }
|
||||||
|
val spanNs = if (received.size >= 2) {
|
||||||
|
received.maxOf { it.tRxNs } - received.minOf { it.tRxNs }
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
// Measured over the arrival span rather than our listening window, which includes the
|
||||||
|
// request round trip and the trailing margin and would understate the rate.
|
||||||
|
val receivedKbps = if (spanNs > 0) (bytes * 8 * 1_000_000 / spanNs).toInt() else 0
|
||||||
|
|
||||||
|
val sender = senderReport(control, credential, sessionId)
|
||||||
|
val sentPackets = sender?.packets ?: 0
|
||||||
|
val lossPct = if (sentPackets > 0) {
|
||||||
|
round2((sentPackets - received.size).coerceAtLeast(0) * 100.0 / sentPackets)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only a run the *clock* ended measured the network. One stopped by our own byte budget
|
||||||
|
// or rate ceiling measured this server.
|
||||||
|
val limitedBy = sender?.limitedBy ?: "unknown"
|
||||||
|
val networkLimited = limitedBy == "duration" &&
|
||||||
|
sender != null && receivedKbps > 0 && receivedKbps < sender.kbps * 9 / 10
|
||||||
|
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
ThroughputMetrics(
|
||||||
|
requestedKbps = kbps,
|
||||||
|
plannedDurationMs = plannedMs,
|
||||||
|
packetsReceived = received.size,
|
||||||
|
bytesReceived = bytes,
|
||||||
|
receivedKbps = receivedKbps,
|
||||||
|
senderPackets = sender?.packets,
|
||||||
|
senderBytes = sender?.bytes,
|
||||||
|
senderKbps = sender?.kbps,
|
||||||
|
lossPct = lossPct,
|
||||||
|
limitedBy = limitedBy,
|
||||||
|
measuresNetwork = networkLimited,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
when {
|
||||||
|
sender == null -> Unit // no sender report: nothing can be concluded, so nothing is
|
||||||
|
received.isEmpty() -> findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.THROUGHPUT_NO_DELIVERY, testId,
|
||||||
|
"No throughput traffic arrived",
|
||||||
|
"The server sent ${sender.packets} packets and none arrived. This is a " +
|
||||||
|
"connectivity fault rather than a slow link.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
networkLimited -> findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.THROUGHPUT_BELOW_OFFERED, testId,
|
||||||
|
"Downstream throughput ${receivedKbps / 1000} Mbit/s, below the " +
|
||||||
|
"${sender.kbps / 1000} Mbit/s offered",
|
||||||
|
"The server sent at ${sender.kbps / 1000} Mbit/s for the full run and " +
|
||||||
|
"${receivedKbps / 1000} Mbit/s arrived" +
|
||||||
|
(lossPct?.let { ", losing $it % of packets" } ?: "") +
|
||||||
|
". The path could not carry what was offered.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = if (received.isEmpty()) TestStatus.FAILED else TestStatus.OK,
|
||||||
|
metrics = metrics,
|
||||||
|
) to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upstream throughput: the client sends, the server counts.
|
||||||
|
*
|
||||||
|
* The mirror image of the downstream case, and it needs no grant — the client is generating
|
||||||
|
* its own traffic, so there is no amplification to gate. What it does need is the server's
|
||||||
|
* count: only the far end knows how much arrived, and without that number a sender can
|
||||||
|
* measure how fast it can *transmit*, which is not the same question and is usually just the
|
||||||
|
* speed of the local NIC.
|
||||||
|
*/
|
||||||
|
fun runUpstream(
|
||||||
|
credential: String,
|
||||||
|
sessionId: String,
|
||||||
|
control: ControlClient,
|
||||||
|
probe: ProbeSession,
|
||||||
|
sessionRef: String,
|
||||||
|
durationS: Int = 5,
|
||||||
|
kbps: Int = 20_000,
|
||||||
|
sizeBytes: Int = 1200,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
|
||||||
|
// Zeroes the server's counter so this run measures itself rather than inheriting the
|
||||||
|
// packets of an earlier one on the same session.
|
||||||
|
val reply = runCatching {
|
||||||
|
control.action(credential, sessionId, """{"action":"throughput","direction":"up"}""")
|
||||||
|
}
|
||||||
|
if (reply.isFailure) {
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.UNSUPPORTED,
|
||||||
|
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "refused"),
|
||||||
|
) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val sent = probe.sendThroughput(durationS * 1000L, kbps, sizeBytes)
|
||||||
|
// A moment for the tail of the run to arrive; counting still-in-flight packets as lost
|
||||||
|
// would inflate the loss figure by whatever the path's delay happens to be.
|
||||||
|
Thread.sleep(500)
|
||||||
|
val seen = upstreamCount(control, credential, sessionId)
|
||||||
|
|
||||||
|
val lossPct = if (sent.packets > 0 && seen != null) {
|
||||||
|
round2((sent.packets - seen.packets).coerceAtLeast(0) * 100.0 / sent.packets)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
// The receiver's rate is the measurement. The sender's is what we managed to emit, which
|
||||||
|
// is a property of this phone and its radio, not of the network.
|
||||||
|
val achievedKbps = seen?.kbps ?: 0
|
||||||
|
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
UpstreamThroughputMetrics(
|
||||||
|
requestedKbps = kbps,
|
||||||
|
sentPackets = sent.packets,
|
||||||
|
sentBytes = sent.bytes,
|
||||||
|
sentKbps = sent.kbps,
|
||||||
|
receivedPackets = seen?.packets,
|
||||||
|
receivedBytes = seen?.bytes,
|
||||||
|
receivedKbps = achievedKbps,
|
||||||
|
lossPct = lossPct,
|
||||||
|
// Same honesty rule as downstream: if what arrived matches what we offered, the
|
||||||
|
// path was never the constraint and this number says nothing about it.
|
||||||
|
measuresNetwork = seen != null && achievedKbps > 0 && achievedKbps < sent.kbps * 9 / 10,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
if (seen != null && seen.packets == 0 && sent.packets > 0) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.THROUGHPUT_NO_DELIVERY, testId,
|
||||||
|
"No upstream traffic reached the server",
|
||||||
|
"This device sent ${sent.packets} packets and the server received none. " +
|
||||||
|
"That is a connectivity fault on the outbound path rather than a slow link.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (lossPct != null && lossPct >= 2.0) {
|
||||||
|
findings.add(
|
||||||
|
finding(
|
||||||
|
FindingRegistry.THROUGHPUT_BELOW_OFFERED, testId,
|
||||||
|
"Upstream loss of $lossPct % at ${sent.kbps / 1000} Mbit/s",
|
||||||
|
"The server received ${seen?.packets} of the ${sent.packets} packets this " +
|
||||||
|
"device sent. The outbound path could not carry what was offered.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.PERF_THROUGHPUT_UDP, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = if (seen == null || seen.packets == 0) TestStatus.FAILED else TestStatus.OK,
|
||||||
|
metrics = metrics,
|
||||||
|
) to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class UpstreamCount(val packets: Int, val bytes: Long, val kbps: Int)
|
||||||
|
|
||||||
|
/** The server's tally for this session's upstream run. */
|
||||||
|
private fun upstreamCount(
|
||||||
|
control: ControlClient, credential: String, sessionId: String,
|
||||||
|
): UpstreamCount? = runCatching {
|
||||||
|
val o = Json.parseToJsonElement(control.observations(credential, sessionId))
|
||||||
|
.jsonObject["throughput_up"]?.jsonObject ?: return null
|
||||||
|
UpstreamCount(
|
||||||
|
packets = o["packets"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
|
||||||
|
bytes = o["bytes"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0,
|
||||||
|
kbps = o["kbps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private data class SenderReport(
|
||||||
|
val packets: Int, val bytes: Long, val kbps: Int, val limitedBy: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The server's own account of the run, from the observations API. */
|
||||||
|
private fun senderReport(
|
||||||
|
control: ControlClient, credential: String, sessionId: String,
|
||||||
|
): SenderReport? = runCatching {
|
||||||
|
val arr = Json.parseToJsonElement(control.observations(credential, sessionId))
|
||||||
|
.jsonObject["throughput"]?.jsonArray ?: return null
|
||||||
|
val last = arr.lastOrNull()?.jsonObject ?: return null
|
||||||
|
SenderReport(
|
||||||
|
packets = last["packets"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
|
||||||
|
bytes = last["bytes"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0,
|
||||||
|
kbps = last["kbps"]?.jsonPrimitive?.content?.toIntOrNull() ?: 0,
|
||||||
|
limitedBy = last["limited_by"]?.jsonPrimitive?.content ?: "unknown",
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun parseInt(body: String?, key: String): Int? =
|
||||||
|
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a finding from a registry entry, which supplies the code, category and severity.
|
||||||
|
*
|
||||||
|
* Taking a [FindingSpec] rather than three loose values is the point: a typo becomes a
|
||||||
|
* compile error, and two call sites cannot disagree about which category a finding belongs
|
||||||
|
* to - a disagreement that would split one fault across two verdict lights.
|
||||||
|
*/
|
||||||
|
private fun finding(spec: FindingSpec, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = spec.code, category = spec.category, severity = spec.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun round2(v: Double) = Math.round(v * 100.0) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metrics for perf.throughput_udp in the upstream direction. */
|
||||||
|
@Serializable
|
||||||
|
data class UpstreamThroughputMetrics(
|
||||||
|
val direction: String = "up",
|
||||||
|
@SerialName("requested_kbps") val requestedKbps: Int,
|
||||||
|
@SerialName("sent_packets") val sentPackets: Int,
|
||||||
|
@SerialName("sent_bytes") val sentBytes: Long,
|
||||||
|
/** What this device managed to emit — a property of the phone and its radio, not the path. */
|
||||||
|
@SerialName("sent_kbps") val sentKbps: Int,
|
||||||
|
@SerialName("received_packets") val receivedPackets: Int? = null,
|
||||||
|
@SerialName("received_bytes") val receivedBytes: Long? = null,
|
||||||
|
/** What arrived, measured by the only party that can measure it. This is the result. */
|
||||||
|
@SerialName("received_kbps") val receivedKbps: Int,
|
||||||
|
@SerialName("loss_pct") val lossPct: Double? = null,
|
||||||
|
@SerialName("measures_network") val measuresNetwork: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Metrics for perf.throughput_udp. */
|
||||||
|
@Serializable
|
||||||
|
data class ThroughputMetrics(
|
||||||
|
val direction: String = "down",
|
||||||
|
@SerialName("requested_kbps") val requestedKbps: Int,
|
||||||
|
@SerialName("planned_duration_ms") val plannedDurationMs: Int,
|
||||||
|
@SerialName("packets_received") val packetsReceived: Int,
|
||||||
|
@SerialName("bytes_received") val bytesReceived: Long,
|
||||||
|
@SerialName("received_kbps") val receivedKbps: Int,
|
||||||
|
@SerialName("sender_packets") val senderPackets: Int? = null,
|
||||||
|
@SerialName("sender_bytes") val senderBytes: Long? = null,
|
||||||
|
@SerialName("sender_kbps") val senderKbps: Int? = null,
|
||||||
|
/** Against the sender's count, so a server-side limit is never counted as network loss. */
|
||||||
|
@SerialName("loss_pct") val lossPct: Double? = null,
|
||||||
|
/** What ended the run: duration | budget | rate | send_error | unknown. */
|
||||||
|
@SerialName("limited_by") val limitedBy: String,
|
||||||
|
/**
|
||||||
|
* Whether this number says anything about the network. False when the sender's own ceiling
|
||||||
|
* was the binding constraint — in which case the rate is a property of the test, not the path.
|
||||||
|
*/
|
||||||
|
@SerialName("measures_network") val measuresNetwork: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// 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.ProbeSession
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* train.udp_updown — the client sends a paced train (types 0x03), then asks the server what
|
||||||
|
* arrived (0x04 → 0x05) and lines both views up per sequence number.
|
||||||
|
*
|
||||||
|
* This is the measurement a round trip cannot make: an echo run only says "lost somewhere", the
|
||||||
|
* train's two ledgers say lost on the way OUT, specifically, because the server's report names
|
||||||
|
* exactly which sequence numbers reached it. The downstream direction has its own test
|
||||||
|
* (train.udp_downstream) under a grant; this one needs none, since the client generates all the
|
||||||
|
* traffic itself.
|
||||||
|
*
|
||||||
|
* The evidence is the schema's columnar TrainEvidence: one index per sent packet, with the
|
||||||
|
* server-side columns null where a packet never arrived. Server timestamps are on the server's
|
||||||
|
* own clock — only differences within that clock mean anything unless time.server_offset maps
|
||||||
|
* them (two-clock rule).
|
||||||
|
*/
|
||||||
|
class UpstreamTrainMeasurement(private val ids: IdSource) {
|
||||||
|
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
fun run(
|
||||||
|
probe: ProbeSession,
|
||||||
|
sessionRef: String,
|
||||||
|
count: Int = 200,
|
||||||
|
sizeBytes: Int = 200,
|
||||||
|
interPacketMs: Long = 5,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val started = ids.monoNs()
|
||||||
|
// The id only needs to be unique within this session; a clash across sessions is
|
||||||
|
// meaningless because trains are buffered per session on the server.
|
||||||
|
val trainId = (System.nanoTime() and 0x7FFFFFFF).toInt()
|
||||||
|
|
||||||
|
val sent = probe.sendTrain(trainId, count, sizeBytes, interPacketMs)
|
||||||
|
// Let the tail arrive before asking for the ledger; packets still in flight when the
|
||||||
|
// report is cut would read as upstream loss.
|
||||||
|
Thread.sleep(300)
|
||||||
|
val report = probe.trainReport(trainId)
|
||||||
|
|
||||||
|
if (report == null) {
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = sessionRef,
|
||||||
|
tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED,
|
||||||
|
// Honest ambiguity: an old server drops 0x04 silently, and a lost report looks
|
||||||
|
// identical from here. Neither says anything about the train itself.
|
||||||
|
error = TestError(
|
||||||
|
"no_report",
|
||||||
|
"no train report arrived — the report was lost, or the server predates trains",
|
||||||
|
),
|
||||||
|
) to emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val bySeq = report.rows.associateBy { it.seq }
|
||||||
|
fun col255(v: Int): Int? = v.takeIf { it != 255 } // 255 = "not observed" on the wire
|
||||||
|
|
||||||
|
val evidence = TrainEvidence(
|
||||||
|
epochMonoNs = started,
|
||||||
|
seq = sent.map { it.seq },
|
||||||
|
tTxNs = sent.map { it.tTxNs },
|
||||||
|
tSrvRxNs = sent.map { bySeq[it.seq]?.tRxNs },
|
||||||
|
tRxNs = sent.map { null }, // upstream only: nothing comes back per packet
|
||||||
|
sizeBytes = sent.map { it.sizeBytes },
|
||||||
|
ttlSeenByServer = sent.map { bySeq[it.seq]?.let { r -> col255(r.ttl) } },
|
||||||
|
dscpSeenByServer = sent.map { bySeq[it.seq]?.let { r -> col255(r.dscp) } },
|
||||||
|
ecnSeenByServer = sent.map { bySeq[it.seq]?.let { r -> col255(r.ecn) } },
|
||||||
|
evidenceTruncated = report.truncated,
|
||||||
|
).toEvidence()
|
||||||
|
|
||||||
|
// Loss against the server's total count, not its row list: rows past the server's buffer
|
||||||
|
// cap are counted but not kept, and treating them as lost would invent loss exactly on
|
||||||
|
// the biggest trains.
|
||||||
|
val lossPct = if (sent.isEmpty()) 0.0 else {
|
||||||
|
(sent.size - report.received).coerceAtLeast(0) * 100.0 / sent.size
|
||||||
|
}
|
||||||
|
val metrics = json.encodeToJsonElement(
|
||||||
|
UpstreamTrainMetrics(
|
||||||
|
sent = sent.size,
|
||||||
|
receivedByServer = report.received,
|
||||||
|
lossPct = round1(lossPct),
|
||||||
|
reportPartsExpected = report.partsExpected,
|
||||||
|
reportPartsReceived = report.partsReceived,
|
||||||
|
truncated = report.truncated,
|
||||||
|
),
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
if (sent.isNotEmpty() && report.received == 0) {
|
||||||
|
findings.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.UDP_UNREACHABLE_UPSTREAM.code,
|
||||||
|
category = FindingRegistry.UDP_UNREACHABLE_UPSTREAM.category,
|
||||||
|
severity = FindingRegistry.UDP_UNREACHABLE_UPSTREAM.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "The server received none of ${sent.size} upstream packets",
|
||||||
|
description = "Every train packet vanished on the way out, while the " +
|
||||||
|
"report request's reply made it back — the outbound path drops this " +
|
||||||
|
"traffic, the return path works.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else if (lossPct >= 2.0) {
|
||||||
|
findings.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.LOSS_UPSTREAM.code,
|
||||||
|
category = FindingRegistry.LOSS_UPSTREAM.category,
|
||||||
|
severity = FindingRegistry.LOSS_UPSTREAM.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = "Upstream loss of ${round1(lossPct)} %",
|
||||||
|
description = "The server received ${report.received} of the ${sent.size} " +
|
||||||
|
"packets this device sent, and its per-sequence ledger names the " +
|
||||||
|
"missing ones. This is outbound loss specifically; the return path " +
|
||||||
|
"delivered the report.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
report.received == 0 && sent.isNotEmpty() -> TestStatus.FAILED
|
||||||
|
report.partsReceived < report.partsExpected -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
return Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = sessionRef, tier = Tier.APP,
|
||||||
|
startedMonoNs = started, endedMonoNs = ids.monoNs(),
|
||||||
|
status = status, evidence = evidence, metrics = metrics,
|
||||||
|
) to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metrics for train.udp_updown. */
|
||||||
|
@Serializable
|
||||||
|
data class UpstreamTrainMetrics(
|
||||||
|
val sent: Int,
|
||||||
|
/** The server's total count — includes packets past its row buffer (counted, not listed). */
|
||||||
|
@SerialName("received_by_server") val receivedByServer: Int,
|
||||||
|
@SerialName("loss_pct") val lossPct: Double,
|
||||||
|
@SerialName("report_parts_expected") val reportPartsExpected: Int,
|
||||||
|
@SerialName("report_parts_received") val reportPartsReceived: Int,
|
||||||
|
/** The server's row buffer overflowed: rows are a sample, the count is still complete. */
|
||||||
|
val truncated: Boolean,
|
||||||
|
)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.engine.Directional.Sample
|
||||||
|
import app.echo_lot.engine.Directional.ServerSighting
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The arithmetic that turns "3 % loss somewhere" into "3 % loss upstream". Getting a denominator
|
||||||
|
* wrong here does not crash anything — it produces a plausible number pointing at the wrong half
|
||||||
|
* of the network, which is worse than no number at all. Hence a test per claim.
|
||||||
|
*/
|
||||||
|
class DirectionalTest {
|
||||||
|
|
||||||
|
/** A clean train: every packet sent, seen and answered. Server clock offset by a constant. */
|
||||||
|
private fun clean(n: Int, offsetNs: Long = 5_000_000_000L): Pair<List<Sample>, List<ServerSighting>> {
|
||||||
|
val sent = (1..n).map { Sample(it, tTxNs = it * 10_000_000L, tRxNs = it * 10_000_000L + 4_000_000L) }
|
||||||
|
val seen = (1..n).map {
|
||||||
|
ServerSighting(it, tRxNs = offsetNs + it * 10_000_000L + 2_000_000L,
|
||||||
|
tTxNs = offsetNs + it * 10_000_000L + 2_100_000L)
|
||||||
|
}
|
||||||
|
return sent to seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCleanTrainReportsNoLossInEitherDirection() {
|
||||||
|
val (sent, seen) = clean(10)
|
||||||
|
val m = Directional.analyse(sent, seen)
|
||||||
|
assertEquals(10, m.sent)
|
||||||
|
assertEquals(10, m.seenByServer)
|
||||||
|
assertEquals(10, m.repliesReceived)
|
||||||
|
assertEquals(0.0, m.lossUpstreamPct)
|
||||||
|
assertEquals(0.0, m.lossDownstreamPct)
|
||||||
|
assertFalse(m.noneReachedServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point: a packet the server never saw was lost on the way there.
|
||||||
|
@Test
|
||||||
|
fun packetsTheServerNeverSawAreUpstreamLoss() {
|
||||||
|
val (sent, seen) = clean(10)
|
||||||
|
val m = Directional.analyse(sent, seen.filter { it.seq !in setOf(3, 7) })
|
||||||
|
assertEquals(2, m.lostUpstream)
|
||||||
|
assertEquals(0, m.lostDownstream)
|
||||||
|
assertEquals(20.0, m.lossUpstreamPct)
|
||||||
|
assertEquals(0.0, m.lossDownstreamPct, "a packet that never arrived cannot be lost coming back")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repliesThatNeverArrivedAreDownstreamLoss() {
|
||||||
|
val (sent, seen) = clean(10)
|
||||||
|
val withHoles = sent.map { if (it.seq in setOf(2, 5)) it.copy(tRxNs = null) else it }
|
||||||
|
val m = Directional.analyse(withHoles, seen)
|
||||||
|
assertEquals(0, m.lostUpstream)
|
||||||
|
assertEquals(2, m.lostDownstream)
|
||||||
|
assertEquals(20.0, m.lossDownstreamPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Downstream loss is measured against what actually reached the server. Using "sent" as the
|
||||||
|
// denominator would count every upstream loss a second time and overstate the return path.
|
||||||
|
@Test
|
||||||
|
fun downstreamLossIsRelativeToWhatReachedTheServer() {
|
||||||
|
val (sent, seen) = clean(10)
|
||||||
|
// 5 lost on the way there; of the 5 that arrived, 1 reply is lost coming back.
|
||||||
|
val seenPartial = seen.filter { it.seq > 5 }
|
||||||
|
val withHole = sent.map {
|
||||||
|
when {
|
||||||
|
it.seq <= 5 -> it.copy(tRxNs = null) // never got there, so never came back
|
||||||
|
it.seq == 6 -> it.copy(tRxNs = null) // arrived, reply lost
|
||||||
|
else -> it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val m = Directional.analyse(withHole, seenPartial)
|
||||||
|
assertEquals(5, m.lostUpstream)
|
||||||
|
assertEquals(50.0, m.lossUpstreamPct)
|
||||||
|
assertEquals(1, m.lostDownstream)
|
||||||
|
assertEquals(20.0, m.lossDownstreamPct, "1 of the 5 that arrived, not 1 of 10")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aServerThatSawNothingIsCalledOutSeparately() {
|
||||||
|
val (sent, _) = clean(6)
|
||||||
|
val m = Directional.analyse(sent.map { it.copy(tRxNs = null) }, emptyList())
|
||||||
|
assertTrue(m.noneReachedServer)
|
||||||
|
assertEquals(100.0, m.lossUpstreamPct)
|
||||||
|
assertEquals(0.0, m.lossDownstreamPct, "with nothing arriving there is no return path to blame")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jitter is legitimate without synchronised clocks because the offset cancels when successive
|
||||||
|
// one-way samples are differenced. This pins that: a huge constant offset must not show up.
|
||||||
|
@Test
|
||||||
|
fun jitterIsUnaffectedByTheClockOffsetBetweenTheTwoMachines() {
|
||||||
|
val (sent, near) = clean(10, offsetNs = 0)
|
||||||
|
val (_, far) = clean(10, offsetNs = 9_999_999_999L)
|
||||||
|
val a = Directional.analyse(sent, near)
|
||||||
|
val b = Directional.analyse(sent, far)
|
||||||
|
assertEquals(a.jitterUpstreamMs, b.jitterUpstreamMs,
|
||||||
|
"a constant clock offset must cancel when consecutive samples are differenced")
|
||||||
|
assertEquals(0.0, assertNotNull(a.jitterUpstreamMs), "an evenly spaced train has no jitter")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun jitterReflectsUnevenArrival() {
|
||||||
|
val sent = listOf(
|
||||||
|
Sample(1, 0, 10_000_000),
|
||||||
|
Sample(2, 10_000_000, 20_000_000),
|
||||||
|
Sample(3, 20_000_000, 30_000_000),
|
||||||
|
)
|
||||||
|
// Server receive times drift: +2ms, +7ms, +3ms relative to send.
|
||||||
|
val seen = listOf(
|
||||||
|
ServerSighting(1, 2_000_000, 2_100_000),
|
||||||
|
ServerSighting(2, 17_000_000, 17_100_000),
|
||||||
|
ServerSighting(3, 23_000_000, 23_100_000),
|
||||||
|
)
|
||||||
|
val m = Directional.analyse(sent, seen)
|
||||||
|
// one-way samples: 2ms, 7ms, 3ms → |7-2| and |3-7| → mean 4.5ms
|
||||||
|
assertEquals(4.5, assertNotNull(m.jitterUpstreamMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
// "No jitter" and "not enough data to say" are different claims, and only one is true here.
|
||||||
|
@Test
|
||||||
|
fun tooFewSamplesReportsNoJitterRatherThanZero() {
|
||||||
|
val m = Directional.analyse(
|
||||||
|
listOf(Sample(1, 0, 10_000_000)),
|
||||||
|
listOf(ServerSighting(1, 2_000_000, 2_100_000)),
|
||||||
|
)
|
||||||
|
assertNull(m.jitterUpstreamMs)
|
||||||
|
assertNull(m.jitterDownstreamMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A server record for a sequence we never sent is not evidence about this train; folding it
|
||||||
|
// in would yield loss percentages outside 0–100.
|
||||||
|
@Test
|
||||||
|
fun strayServerRecordsAreIgnored() {
|
||||||
|
val (sent, seen) = clean(5)
|
||||||
|
val m = Directional.analyse(sent, seen + ServerSighting(99, 1, 2) + ServerSighting(100, 3, 4))
|
||||||
|
assertEquals(5, m.seenByServer)
|
||||||
|
assertEquals(0.0, m.lossUpstreamPct)
|
||||||
|
assertTrue(m.lossDownstreamPct in 0.0..100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun anEmptyTrainDoesNotDivideByZero() {
|
||||||
|
val m = Directional.analyse(emptyList(), emptyList())
|
||||||
|
assertEquals(0.0, m.lossUpstreamPct)
|
||||||
|
assertEquals(0.0, m.lossDownstreamPct)
|
||||||
|
assertFalse(m.noneReachedServer, "nothing sent is not the same as nothing arriving")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,81 @@
|
|||||||
|
// 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}")
|
||||||
|
|
||||||
|
// Assert on what is present, not on how many: adding a measurement should not be a
|
||||||
|
// test edit. (It was, once — hence the note.)
|
||||||
|
assertTrue(tests.size >= 3, "expected at least the three downstream tests, got ${tests.size}")
|
||||||
|
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"))
|
||||||
|
|
||||||
|
// Fragment ordering runs only when fragments arrive at all, and only against a server
|
||||||
|
// that can craft them — so it is checked when present rather than required.
|
||||||
|
byType[TestType.MTU_FRAG_ORDERING]?.let { fo ->
|
||||||
|
val m = fo.metrics?.toString() ?: ""
|
||||||
|
println("fragment ordering: ${fo.status} $m")
|
||||||
|
if (fo.status != TestStatus.UNSUPPORTED) {
|
||||||
|
assertTrue(m.contains("in_order"), "no per-ordering result: $m")
|
||||||
|
assertTrue(m.contains("reversed"), "reversed ordering was never attempted: $m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,79 @@
|
|||||||
|
// 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.assertNotNull
|
||||||
|
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"))
|
||||||
|
|
||||||
|
// The directional split is the point of asking the server what it saw: without it a
|
||||||
|
// lossy path is reported as "loss" with no direction, which sends an engineer looking
|
||||||
|
// in both at once. Correlation is by wire sequence number, so a mismatch here means the
|
||||||
|
// two sides disagree about which packet is which.
|
||||||
|
val m = metrics.toString()
|
||||||
|
assertTrue(m.contains("seen_by_server"), "no directional split in the metrics: $m")
|
||||||
|
val seen = Regex(""""seen_by_server":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
assertNotNull(seen, "seen_by_server missing")
|
||||||
|
assertEquals(20, seen, "the server should have seen every probe on a healthy path")
|
||||||
|
assertTrue(m.contains("jitter_upstream_ms"), "no per-direction jitter: $m")
|
||||||
|
println("directional: $m")
|
||||||
|
|
||||||
|
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,105 @@
|
|||||||
|
// 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.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downstream throughput against a LIVE server. Self-skips without ECHOLOT_LIVE_*.
|
||||||
|
*
|
||||||
|
* The assertions are about *honesty* rather than speed: a rate is only a measurement if the run
|
||||||
|
* was ended by the clock and the sender's own count backs it up. A test that just asserted "some
|
||||||
|
* Mbps arrived" would pass equally well against a broken implementation.
|
||||||
|
*/
|
||||||
|
class LiveThroughputTest {
|
||||||
|
|
||||||
|
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 measuresDownstreamRateAndSaysWhatLimitedIt() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveThroughputTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin), "0.2.0")
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
ps.echo() // prime: the grant binds to the observed source
|
||||||
|
ThroughputMeasurement(SystemIdSource()).run(
|
||||||
|
cred, session.sessionId, control, ps, sessionRef = "sess-1",
|
||||||
|
durationS = 3, kbps = 20_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
|
||||||
|
val m = assertNotNull(test.metrics).toString()
|
||||||
|
println("throughput: ${test.status} $m")
|
||||||
|
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
|
||||||
|
|
||||||
|
assertEquals(TestStatus.OK, test.status, "no throughput traffic arrived: $m")
|
||||||
|
|
||||||
|
// The sender's own count must be present — without it, loss cannot be attributed and the
|
||||||
|
// number is not a measurement.
|
||||||
|
assertTrue(m.contains("sender_packets"), "no sender report to compare against: $m")
|
||||||
|
assertTrue(m.contains("limited_by"), "the result must say what ended the run: $m")
|
||||||
|
|
||||||
|
val received = Regex(""""received_kbps":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
assertNotNull(received)
|
||||||
|
assertTrue(received > 0, "measured 0 kbps: $m")
|
||||||
|
println("received ${received / 1000} Mbit/s")
|
||||||
|
|
||||||
|
// A run this short and this far below the ceiling should end on the clock. Anything else
|
||||||
|
// means the grant was the constraint, and then the rate says nothing about the path.
|
||||||
|
assertTrue(m.contains(""""limited_by":"duration""""),
|
||||||
|
"the run did not end on the clock, so the rate measures the server, not the path: $m")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream is the direction only the far end can measure. The assertion that matters is that
|
||||||
|
// the server's count is present and plausible against what we sent — a test that only checked
|
||||||
|
// "we transmitted some Mbps" would pass against a server that counted nothing at all.
|
||||||
|
@Test
|
||||||
|
fun measuresUpstreamAgainstTheServersCount() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveThroughputTest(up) skipped"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin), "0.2.0")
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
ps.echo()
|
||||||
|
ThroughputMeasurement(SystemIdSource()).runUpstream(
|
||||||
|
cred, session.sessionId, control, ps, sessionRef = "sess-1",
|
||||||
|
durationS = 3, kbps = 10_000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
|
||||||
|
val m = assertNotNull(test.metrics).toString()
|
||||||
|
println("upstream: ${test.status} $m")
|
||||||
|
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
|
||||||
|
|
||||||
|
assertEquals(TestStatus.OK, test.status, "the server counted nothing: $m")
|
||||||
|
val recv = Regex(""""received_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
val sent = Regex(""""sent_packets":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
assertNotNull(recv); assertNotNull(sent)
|
||||||
|
assertTrue(sent > 100, "barely anything was sent, so the rate means nothing: $m")
|
||||||
|
assertTrue(recv > 0, "the server received none of $sent packets: $m")
|
||||||
|
// The counts should be close on a healthy path; wildly different means the two sides are
|
||||||
|
// counting different things rather than the network losing packets.
|
||||||
|
assertTrue(recv <= sent, "the server counted MORE than we sent — the counter is not being reset")
|
||||||
|
println("sent $sent, server saw $recv")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,65 @@
|
|||||||
|
// 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.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upstream train (types 0x03-0x05) against a LIVE server. Self-skips without ECHOLOT_LIVE_*.
|
||||||
|
*
|
||||||
|
* What is asserted is the ledger property: the server's report must account for what was sent,
|
||||||
|
* per sequence number, because directional loss attribution is the entire reason trains exist —
|
||||||
|
* a test that only checked "a report came back" would pass against a server that counts nothing.
|
||||||
|
*/
|
||||||
|
class LiveUpstreamTrainTest {
|
||||||
|
|
||||||
|
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 serverLedgerAccountsForTheTrain() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveUpstreamTrainTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin), "0.2.0")
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
val (test, findings) = ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
ps.echo() // prime the session so its source is known
|
||||||
|
UpstreamTrainMeasurement(SystemIdSource()).run(
|
||||||
|
ps, sessionRef = "sess-1", count = 120, sizeBytes = 200, interPacketMs = 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
|
||||||
|
val m = assertNotNull(test.metrics).toString()
|
||||||
|
println("updown: ${test.status} $m")
|
||||||
|
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
|
||||||
|
|
||||||
|
assertEquals(TestStatus.OK, test.status, "train report incomplete or absent: $m")
|
||||||
|
|
||||||
|
val sent = Regex(""""sent":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
val received = Regex(""""received_by_server":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
|
||||||
|
assertNotNull(sent); assertNotNull(received)
|
||||||
|
assertTrue(sent > 0, "nothing was sent: $m")
|
||||||
|
// Over a working path the ledger must be near-complete; a lossy wifi may drop a few, but
|
||||||
|
// a server that fails to count would show up as massive phantom loss here.
|
||||||
|
assertTrue(received >= sent * 9 / 10, "server counted $received of $sent: $m")
|
||||||
|
|
||||||
|
// The columnar evidence must carry a server timestamp for arrived packets — that column
|
||||||
|
// is what one-way delay math consumes after timesync.
|
||||||
|
val ev = assertNotNull(test.evidence).toString()
|
||||||
|
assertTrue(ev.contains("t_srv_rx_ns"), "no server rx column in evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,128 @@
|
|||||||
|
// 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 constraints: Constraints = Constraints(),
|
||||||
|
val notes: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What limited this run — the counterpart to [Tiers], which records what was available.
|
||||||
|
*
|
||||||
|
* A constrained run is not a failed run, and it is not a normal one either. Without this, a run
|
||||||
|
* taken through a VPN looks exactly like a clean run of a healthy network: the same shape, the
|
||||||
|
* same green verdict, and no way for a reader — or a server aggregating thousands of these — to
|
||||||
|
* know that almost nothing was actually measured.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class Constraints(
|
||||||
|
/** A VPN held the default route while this ran. */
|
||||||
|
@SerialName("vpn_active") val vpnActive: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Per-network probing was refused by the OS.
|
||||||
|
*
|
||||||
|
* Android blocks `Network.bindSocket()` on the underlying networks whenever a VPN is up, to
|
||||||
|
* stop apps leaking around the tunnel. Every per-network test then measures nothing, so any
|
||||||
|
* conclusion drawn about the wifi or cellular link underneath is unfounded.
|
||||||
|
*/
|
||||||
|
@SerialName("per_network_blocked") val perNetworkBlocked: Boolean = false,
|
||||||
|
/** Networks that could not be measured, by id. */
|
||||||
|
@SerialName("unmeasured_networks") val unmeasuredNetworks: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
/** True when this run's results mean something different from an unconstrained one. */
|
||||||
|
val constrained: Boolean get() = vpnActive || perNetworkBlocked
|
||||||
|
}
|
||||||
|
|
||||||
|
@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,321 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The registry of finding codes (measurement-schema.md §9, open item 1).
|
||||||
|
*
|
||||||
|
* A finding code is the stable, machine-readable half of a result: the prose changes, the code is
|
||||||
|
* what a dashboard groups by and what someone greps a year of archived runs for. That only holds
|
||||||
|
* if a code means exactly one thing forever — which is not something ad-hoc string literals at
|
||||||
|
* fifteen call sites can promise.
|
||||||
|
*
|
||||||
|
* The failure this exists to prevent had already happened by the time it was written. Two
|
||||||
|
* independently-added emitters produced `connectivity.downstream_loss` and
|
||||||
|
* `connectivity.loss_downstream` for the same concept, and nothing anywhere objected. Anyone
|
||||||
|
* aggregating either one would have silently seen half their data.
|
||||||
|
*
|
||||||
|
* So codes are declared here as typed specs, each carrying its category and default severity, and
|
||||||
|
* emitters reference the spec rather than retyping the string. That makes a typo a compile error,
|
||||||
|
* and makes it impossible for two call sites to disagree about which category a finding belongs
|
||||||
|
* to — a disagreement that would otherwise split one fault across two verdict lights.
|
||||||
|
*/
|
||||||
|
data class FindingSpec(
|
||||||
|
val code: String,
|
||||||
|
val category: Category,
|
||||||
|
/** Severity when nothing about the specific run argues otherwise; emitters may escalate. */
|
||||||
|
val severity: Severity,
|
||||||
|
/** One line: what this finding asserts. Present tense, no hedging. */
|
||||||
|
val meaning: String,
|
||||||
|
/**
|
||||||
|
* What the finding rules *out*, where that is the useful half. "Loss upstream" is worth much
|
||||||
|
* more when it also says the return path is fine, because that halves where to look next.
|
||||||
|
*/
|
||||||
|
val rulesOut: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
object FindingRegistry {
|
||||||
|
|
||||||
|
// ---- connectivity ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// Renamed from nat.* before anything shipped: neither of these is about NAT, and the
|
||||||
|
// prefix is what decides which category - and therefore which verdict light - a finding
|
||||||
|
// rolls up into. A nat.* code landing under connectivity would be a permanent puzzle.
|
||||||
|
val UDP_UNREACHABLE = FindingSpec(
|
||||||
|
"connectivity.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH,
|
||||||
|
"No UDP echo replies came back from the server at all.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val UDP_UNREACHABLE_UPSTREAM = FindingSpec(
|
||||||
|
"connectivity.udp_unreachable_upstream", Category.CONNECTIVITY, Severity.HIGH,
|
||||||
|
"The server received none of the probes, so traffic is dropped on the way out.",
|
||||||
|
rulesOut = "The return path: nothing arrived to be replied to.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val UDP_LOSS = FindingSpec(
|
||||||
|
"connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||||
|
"A large fraction of round-trip probes were lost, direction unknown.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val LOSS_UPSTREAM = FindingSpec(
|
||||||
|
"connectivity.loss_upstream", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||||
|
"Probes were lost on the way to the server.",
|
||||||
|
rulesOut = "The return path: replies came back for everything that arrived.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single code for "lost on the return path", whichever measurement found it.
|
||||||
|
*
|
||||||
|
* Two emitters had independently invented `connectivity.downstream_loss` and
|
||||||
|
* `connectivity.loss_downstream` for this, and nothing objected. Anyone aggregating either
|
||||||
|
* one would have silently seen half their data. Paired with [LOSS_UPSTREAM] so the two
|
||||||
|
* directions read as a set.
|
||||||
|
*/
|
||||||
|
val LOSS_DOWNSTREAM = FindingSpec(
|
||||||
|
"connectivity.loss_downstream", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||||
|
"Packets were lost on the way back from the server.",
|
||||||
|
rulesOut = "The outbound path: the server received what it was answering.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val DOWNSTREAM_BLOCKED = FindingSpec(
|
||||||
|
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH,
|
||||||
|
"Server-initiated packets never arrive, although round trips work.",
|
||||||
|
rulesOut = "Basic reachability: the path forwards replies, just not unsolicited traffic.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val DOWNSTREAM_REORDER = FindingSpec(
|
||||||
|
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW,
|
||||||
|
"Downstream packets arrive in a different order than they were sent.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// MEDIUM, not HIGH: a captive portal is a condition to report, not necessarily a fault - on
|
||||||
|
// hotel or cafe wifi it is exactly what should be there, and logging in clears it. NO_INTERNET
|
||||||
|
// is the HIGH one, because nothing the user does locally fixes that. The registry first said
|
||||||
|
// HIGH; the probe emitting it had always said MEDIUM, and the probe was the considered value.
|
||||||
|
val CAPTIVE_PORTAL = FindingSpec(
|
||||||
|
"connectivity.captive_portal", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||||
|
"A captive portal is intercepting connectivity checks.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val NO_INTERNET = FindingSpec(
|
||||||
|
"connectivity.no_internet", Category.CONNECTIVITY, Severity.HIGH,
|
||||||
|
"Android's own connectivity checks fail on this network.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- mtu -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
||||||
|
"mtu.reduced_downstream", Category.MTU, Severity.LOW,
|
||||||
|
"The downstream path MTU is below the usual 1500 bytes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val MTU_DOWNSTREAM_BLACKHOLE = FindingSpec(
|
||||||
|
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM,
|
||||||
|
"Datagrams above the path MTU are dropped downstream, fragmented or not.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val FRAGMENTS_BLOCKED = FindingSpec(
|
||||||
|
"mtu.fragments_blocked", Category.MTU, Severity.MEDIUM,
|
||||||
|
"IP fragments do not reach this device even when sent in order.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val FRAGMENT_REORDER_SENSITIVE = FindingSpec(
|
||||||
|
"mtu.fragment_reorder_sensitive", Category.MTU, Severity.LOW,
|
||||||
|
"Fragments are delivered in order but dropped when reordered or delayed.",
|
||||||
|
rulesOut = "Fragmentation itself: in-order fragments arrive fine.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- nat -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
val NAT_UDP_REBINDING = FindingSpec(
|
||||||
|
"nat.udp_rebinding", Category.NAT, Severity.MEDIUM,
|
||||||
|
"A NAT remapped the UDP source port mid-flow.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val NAT_SYMMETRIC = FindingSpec(
|
||||||
|
"nat.symmetric", Category.NAT, Severity.MEDIUM,
|
||||||
|
"The NAT assigns a different external port per destination.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- perf ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
val THROUGHPUT_NO_DELIVERY = FindingSpec(
|
||||||
|
"perf.throughput_no_delivery", Category.PERFORMANCE, Severity.HIGH,
|
||||||
|
"No throughput traffic arrived, although the server sent it.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val THROUGHPUT_BELOW_OFFERED = FindingSpec(
|
||||||
|
"perf.throughput_below_offered", Category.PERFORMANCE, Severity.LOW,
|
||||||
|
"Less throughput arrived than the server sent for the whole run.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- dns -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
val DNS_ANSWER_REWRITTEN = FindingSpec(
|
||||||
|
"dns.answer_rewritten", Category.DNS, Severity.HIGH,
|
||||||
|
"A resolver returned an answer that differs from the authoritative record.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val DNS_AUTHORITATIVE_UNREACHABLE = FindingSpec(
|
||||||
|
"dns.authoritative_unreachable", Category.DNS, Severity.MEDIUM,
|
||||||
|
"The canary zone's authoritative server could not be reached.",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- v6 ----------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Prefix is `v6.`, matching the test-type registry (v6.brokenness, v6.happy_eyeballs, ...).
|
||||||
|
// These were `ipv6.*` while declaring Category.IPV6, but the prefix map only knows "v6", so
|
||||||
|
// they silently rolled up under connectivity: the third instance of a prefix disagreeing with
|
||||||
|
// its category and quietly moving a fault to a different verdict light.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renamed from `v6.broken`, which claimed more than the evidence supports.
|
||||||
|
*
|
||||||
|
* The only signal behind it is ICMPv6 echo getting no reply — and ICMPv6 echo is widely
|
||||||
|
* filtered on networks where IPv6 otherwise works perfectly. A phone that reported this while
|
||||||
|
* happily loading an IPv6-only site over TCP is what caught it. From here the two cases look
|
||||||
|
* identical, so the finding now says what was observed and names both explanations rather than
|
||||||
|
* picking one.
|
||||||
|
*
|
||||||
|
* It is worth reporting either way: filtered ICMPv6 breaks Path MTU Discovery, which is its
|
||||||
|
* own fault even when IPv6 works.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* A global IPv6 address with no default route.
|
||||||
|
*
|
||||||
|
* This is the structural version of the same complaint, and it is worth far more than the
|
||||||
|
* ICMP one because it admits no other explanation: the device has an address it cannot route
|
||||||
|
* with. Nothing is filtered, nothing is inferred — the routing table says so directly, and it
|
||||||
|
* is already in the link snapshot.
|
||||||
|
*
|
||||||
|
* Not always a fault. A VPN that installs host routes to specific destinations produces
|
||||||
|
* exactly this shape on purpose, and it works. What makes it worth reporting either way is
|
||||||
|
* that applications cannot tell: having a global address, they will try IPv6 first and stall
|
||||||
|
* for every destination the routes do not cover.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* An IPv6 default route with no global address to use it from — the mirror of
|
||||||
|
* [V6_NO_DEFAULT_ROUTE], and the more common misconfiguration of the two.
|
||||||
|
*
|
||||||
|
* The router is sending RAs that name it as a default gateway, but SLAAC produced no address:
|
||||||
|
* no prefix information option, or a prefix without the autonomous flag, or DHCPv6-only
|
||||||
|
* addressing the device did not complete. The network is announcing IPv6 service it does not
|
||||||
|
* actually deliver.
|
||||||
|
*
|
||||||
|
* This is worth flagging above the ICMP signal because it is both certain and consequential.
|
||||||
|
* Hosts see router advertisements, believe IPv6 is available, and pay a connection-attempt
|
||||||
|
* timeout on every dual-stack destination before falling back to IPv4 — the classic "the
|
||||||
|
* internet feels slow" complaint with no packet loss anywhere to explain it.
|
||||||
|
*/
|
||||||
|
val V6_ROUTE_WITHOUT_ADDRESS = FindingSpec(
|
||||||
|
"v6.route_without_address", Category.IPV6, Severity.MEDIUM,
|
||||||
|
"The network advertises an IPv6 default route but the device has no global IPv6 address.",
|
||||||
|
rulesOut = "A working IPv6 setup: SLAAC did not produce a usable address on this link.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A VPN prevented the underlying networks from being measured.
|
||||||
|
*
|
||||||
|
* Reported rather than worked around: Android refuses `Network.bindSocket()` on the networks
|
||||||
|
* beneath a VPN precisely so apps cannot leak around the tunnel, and that is correct
|
||||||
|
* behaviour. What is not acceptable is a run that quietly measures nothing and calls the
|
||||||
|
* result healthy, so this says plainly which networks went unmeasured and why.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The network's DNS server answers, but this device cannot resolve through it.
|
||||||
|
*
|
||||||
|
* Worth separating from every other DNS failure because the remedy is somewhere else entirely.
|
||||||
|
* A name that will not resolve looks identical to a user whatever the cause, and the two causes
|
||||||
|
* pull in opposite directions: a server that does not answer means the network is broken and
|
||||||
|
* the router is the thing to examine, while a server that answers a direct query on a device
|
||||||
|
* that still cannot resolve means the platform resolver has wedged — fixed by toggling wifi,
|
||||||
|
* and nothing to do with the network at all.
|
||||||
|
*
|
||||||
|
* Proven rather than inferred: the probe sends its own UDP query, bypassing the component under
|
||||||
|
* suspicion, and compares that against what the platform returns for the same name.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* The network hands out a search domain its DNS server will not answer for.
|
||||||
|
*
|
||||||
|
* A resolver appends search domains to lookups, so every name a client asks about can stall on
|
||||||
|
* a domain the server ignores. The failure mode is silence rather than a negative answer, and
|
||||||
|
* silence is indistinguishable from packet loss: clients retry instead of moving on, and some
|
||||||
|
* give up on the lookup entirely. That makes it look like the device is broken when the
|
||||||
|
* network is.
|
||||||
|
*
|
||||||
|
* Whether it bites depends on the resolver — some try the bare name first and never notice —
|
||||||
|
* which is why two devices on the same network can disagree about whether DNS works.
|
||||||
|
*/
|
||||||
|
val DNS_SEARCH_DOMAIN_UNANSWERED = FindingSpec(
|
||||||
|
"dns.search_domain_unanswered", Category.DNS, Severity.HIGH,
|
||||||
|
"The network advertises a DNS search domain that its own server does not answer for.",
|
||||||
|
rulesOut = "A fault on this device: the same server answers ordinary names normally.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val DNS_SYSTEM_RESOLVER_BROKEN = FindingSpec(
|
||||||
|
"dns.system_resolver_broken", Category.DNS, Severity.HIGH,
|
||||||
|
"The network's DNS server answers, but this device cannot resolve names through it.",
|
||||||
|
rulesOut = "A network fault: the server replied to a query sent from this device.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val MEASUREMENT_VPN_CONSTRAINED = FindingSpec(
|
||||||
|
"measurement.vpn_constrained", Category.CONNECTIVITY, Severity.INFO,
|
||||||
|
"A VPN was active, so the networks underneath it could not be measured.",
|
||||||
|
rulesOut = "Nothing — this run says little about the underlying network either way.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val V6_NO_DEFAULT_ROUTE = FindingSpec(
|
||||||
|
"v6.no_default_route", Category.IPV6, Severity.MEDIUM,
|
||||||
|
"The device has a global IPv6 address but no IPv6 default route.",
|
||||||
|
rulesOut = "Guesswork: this is read from the routing table, not inferred from silence.",
|
||||||
|
)
|
||||||
|
|
||||||
|
val V6_NO_ICMP_REPLY = FindingSpec(
|
||||||
|
"v6.no_icmp_reply", Category.IPV6, Severity.LOW,
|
||||||
|
"IPv6 is configured but ICMPv6 echo gets no reply.",
|
||||||
|
rulesOut = "Nothing on its own: IPv6 may work fine with ICMP filtered.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPv6 is advertised and does not work — the claim `v6.broken` originally made on ICMP
|
||||||
|
* silence alone, now reinstated because it can finally be backed: it is only emitted when a
|
||||||
|
* real IPv6 TCP connection (v6.brokenness) failed on the same network whose ICMPv6 went
|
||||||
|
* unanswered. Two independent transports failing on a network that advertises IPv6 is what
|
||||||
|
* "broken" actually means; either signal alone still gets [V6_NO_ICMP_REPLY].
|
||||||
|
*/
|
||||||
|
val V6_BROKEN = FindingSpec(
|
||||||
|
"v6.broken", Category.IPV6, Severity.HIGH,
|
||||||
|
"IPv6 is advertised on this network but carries no traffic.",
|
||||||
|
rulesOut = "ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INFO deliberately, and it needs to stay that way.
|
||||||
|
*
|
||||||
|
* Most networks still do not offer IPv6, and that is not a fault. Reporting it as a warning
|
||||||
|
* lights a yellow verdict on a perfectly healthy network, which teaches people to ignore the
|
||||||
|
* light — the one thing a diagnostic must never do.
|
||||||
|
*/
|
||||||
|
val V6_NOT_OFFERED = FindingSpec(
|
||||||
|
"v6.not_offered", Category.IPV6, Severity.INFO,
|
||||||
|
"This network does not offer IPv6.",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Every registered finding, in declaration order. */
|
||||||
|
val all: List<FindingSpec> = listOf(
|
||||||
|
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
||||||
|
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
|
||||||
|
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
||||||
|
FRAGMENT_REORDER_SENSITIVE,
|
||||||
|
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||||
|
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
||||||
|
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
||||||
|
DNS_SEARCH_DOMAIN_UNANSWERED, DNS_SYSTEM_RESOLVER_BROKEN, MEASUREMENT_VPN_CONSTRAINED,
|
||||||
|
V6_NO_DEFAULT_ROUTE, V6_ROUTE_WITHOUT_ADDRESS, V6_NO_ICMP_REPLY, V6_BROKEN, V6_NOT_OFFERED,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
|
||||||
|
|
||||||
|
fun byCode(code: String): FindingSpec? = byCode[code]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// 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(),
|
||||||
|
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Android itself concluded about a network, as opposed to what we measured.
|
||||||
|
*
|
||||||
|
* Recorded because it is the verdict the user can see — the "no internet" warning in the status
|
||||||
|
* bar — and because it is free: the platform has already done the work by the time a run starts.
|
||||||
|
*
|
||||||
|
* Its real value is disagreement. When Android says a network is unusable and our own probes reach
|
||||||
|
* the internet regardless, the fault is in the device rather than the network, and that distinction
|
||||||
|
* is the difference between "fix your router" and "toggle your wifi". Neither number alone can say
|
||||||
|
* that; only the two together.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SystemVerdict(
|
||||||
|
/** Android's own connectivity check passed. Null when the platform did not say. */
|
||||||
|
val validated: Boolean? = null,
|
||||||
|
/** Android believes a captive portal is intercepting this network. */
|
||||||
|
@SerialName("captive_portal") val captivePortal: Boolean? = null,
|
||||||
|
/** Some traffic works and some does not — Android's own hedge. */
|
||||||
|
@SerialName("partial_connectivity") val partialConnectivity: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@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,103 @@
|
|||||||
|
// 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.
|
||||||
|
* - A run whose per-network probing was blocked is `inconclusive` outright, whatever the
|
||||||
|
* categories say. The lights describe what the tests found; when the OS refused to let the
|
||||||
|
* tests run, a green light would describe nothing at all.
|
||||||
|
*
|
||||||
|
* 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 =
|
||||||
|
derive(tests, findings, Constraints())
|
||||||
|
|
||||||
|
fun derive(tests: List<Test>, findings: List<Finding>, constraints: Constraints): 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A run that could not measure the networks it was asked about has not found them
|
||||||
|
// healthy; it has found out nothing. Reporting that as green is the single most
|
||||||
|
// misleading thing this function could do, so the constraint outranks the lights.
|
||||||
|
val overall = if (constraints.perNetworkBlocked) {
|
||||||
|
Verdict.INCONCLUSIVE
|
||||||
|
} else {
|
||||||
|
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,160 @@
|
|||||||
|
// 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"
|
||||||
|
/** Whether fragments survive arriving out of order, not merely whether they survive. */
|
||||||
|
const val MTU_FRAG_ORDERING = "mtu.frag_ordering"
|
||||||
|
// 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"
|
||||||
|
/**
|
||||||
|
* Does this device's own resolver work, as distinct from the network's DNS.
|
||||||
|
*
|
||||||
|
* Registry addition, v1.1. Kept apart from [DNS_CANARY], which asks whether answers are being
|
||||||
|
* tampered with; this asks whether answers arrive at all, and where the failure sits.
|
||||||
|
*/
|
||||||
|
const val DNS_RESOLVER = "dns.resolver"
|
||||||
|
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,73 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two ways a network can be half-configured for IPv6, read from the link snapshot.
|
||||||
|
*
|
||||||
|
* Pure model logic rather than something a ViewModel does, because "is this network's IPv6
|
||||||
|
* broken, and in which direction" is exactly the kind of judgement that should be checkable
|
||||||
|
* against a captured routing table without a phone in the loop.
|
||||||
|
*/
|
||||||
|
object V6Analysis {
|
||||||
|
|
||||||
|
/** Linux tunnel interfaces: WireGuard/Netbird (tun*, wg*), plus the usual VPN names. */
|
||||||
|
private val TUNNEL_IFACE = Regex("""^(tun|tap|wg|ppp|ipsec|utun)\d*$""")
|
||||||
|
|
||||||
|
/** What one network's IPv6 configuration looks like. */
|
||||||
|
data class Shape(
|
||||||
|
val iface: String,
|
||||||
|
/** A global address with no ::/0 route: an address the device cannot route with. */
|
||||||
|
val addressWithoutRoute: Boolean,
|
||||||
|
/** A ::/0 route with no global address: a route the device cannot source from. */
|
||||||
|
val routeWithoutAddress: Boolean,
|
||||||
|
/** The routes belong to a tunnel, so a partial view of IPv6 is likely deliberate. */
|
||||||
|
val tunnel: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies each network's IPv6 configuration.
|
||||||
|
*
|
||||||
|
* Both shapes are read straight from the link snapshot rather than inferred from silence, so
|
||||||
|
* unlike an ICMP signal there is no competing explanation for what was observed — and both
|
||||||
|
* matter for the same reason: an application cannot tell in advance, so it tries IPv6 first
|
||||||
|
* and waits.
|
||||||
|
*
|
||||||
|
* They differ in what they mean. An address with no route is what a VPN installing host routes
|
||||||
|
* to specific destinations produces on purpose, and it works; calling that a fault would be the
|
||||||
|
* "lack of IPv6 is a yellow condition" mistake in a new costume, so a tunnel downgrades it to
|
||||||
|
* information. A route with no address is the opposite: the router advertised itself as a
|
||||||
|
* default gateway but SLAAC produced nothing usable, so the network is announcing IPv6 service
|
||||||
|
* it does not deliver. That one is a real misconfiguration however it arises.
|
||||||
|
*/
|
||||||
|
fun classify(networks: List<Network>): List<Shape> = networks.map { n ->
|
||||||
|
val globalV6 = n.link.addresses.any { isGlobalV6(it.addr) }
|
||||||
|
val v6Routes = n.link.routes.filter { it.dst.contains(':') }
|
||||||
|
val hasDefault = v6Routes.any { it.dst == "::/0" }
|
||||||
|
Shape(
|
||||||
|
iface = n.iface ?: v6Routes.firstOrNull()?.iface.orEmpty(),
|
||||||
|
addressWithoutRoute = globalV6 && !hasDefault,
|
||||||
|
routeWithoutAddress = hasDefault && !globalV6,
|
||||||
|
// Android labels the transport itself, which beats guessing from a name; the regex
|
||||||
|
// stays as a backstop for tunnels Android does not own (a userspace WireGuard, say,
|
||||||
|
// or anything seen through the shell tier).
|
||||||
|
tunnel = n.transport == Transport.VPN ||
|
||||||
|
v6Routes.any { TUNNEL_IFACE.containsMatchIn(it.iface.orEmpty()) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an address is IPv6 and usable as a source for off-link traffic.
|
||||||
|
*
|
||||||
|
* ULAs count. A ULA is not globally routable, but it is a global-*scope* address the stack
|
||||||
|
* will happily select as a source, which is the property that matters here — an overlay
|
||||||
|
* network handing out fc00::/7 addresses is providing working IPv6 to the destinations it
|
||||||
|
* carries, and treating that as "no address" would misreport every VPN as broken.
|
||||||
|
*/
|
||||||
|
private fun isGlobalV6(addr: String): Boolean {
|
||||||
|
if (!addr.contains(':')) return false
|
||||||
|
val a = addr.substringBefore('%').lowercase() // strip any zone index
|
||||||
|
return !a.startsWith("fe80") && a != "::1" && a != "::"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the finding registry honest.
|
||||||
|
*
|
||||||
|
* The interesting test is the last one: it reads `docs/findings-registry.md` and fails when the
|
||||||
|
* document and the code disagree. Documentation that drifts from its implementation is worse than
|
||||||
|
* none, because it still looks authoritative — and a finding registry is precisely the artifact
|
||||||
|
* other people build tooling against.
|
||||||
|
*/
|
||||||
|
class FindingRegistryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun codesAreUnique() {
|
||||||
|
val dupes = FindingRegistry.all.groupBy { it.code }.filterValues { it.size > 1 }.keys
|
||||||
|
assertTrue(dupes.isEmpty(), "duplicate finding codes: $dupes")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyDeclaredSpecIsInTheAllList() {
|
||||||
|
// Reflection over the object's properties: a spec that is declared but left out of `all`
|
||||||
|
// is invisible to the doc check and to any consumer enumerating the registry.
|
||||||
|
val declared = FindingRegistry::class.java.declaredMethods
|
||||||
|
.filter { it.parameterCount == 0 && it.returnType == FindingSpec::class.java }
|
||||||
|
.mapNotNull { runCatching { it.invoke(FindingRegistry) as FindingSpec }.getOrNull() }
|
||||||
|
.map { it.code }
|
||||||
|
.toSet()
|
||||||
|
val listed = FindingRegistry.all.map { it.code }.toSet()
|
||||||
|
assertEquals(declared, listed, "declared specs and the `all` list disagree")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The prefix decides the category, and the category decides which verdict light the finding
|
||||||
|
// rolls up into. A code whose prefix disagrees with its category silently moves a fault to a
|
||||||
|
// different light — the exact bug that got two codes renamed out of nat.*.
|
||||||
|
@Test
|
||||||
|
fun everyPrefixMatchesItsCategory() {
|
||||||
|
for (spec in FindingRegistry.all) {
|
||||||
|
val fromPrefix = TestType.category(spec.code)
|
||||||
|
assertEquals(
|
||||||
|
fromPrefix, spec.category,
|
||||||
|
"${spec.code} is declared as ${spec.category} but its prefix maps to $fromPrefix",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun codesFollowTheNamingConvention() {
|
||||||
|
val shape = Regex("^[a-z0-9]+\\.[a-z0-9_]+$")
|
||||||
|
for (spec in FindingRegistry.all) {
|
||||||
|
assertTrue(shape.matches(spec.code), "malformed code: ${spec.code}")
|
||||||
|
assertTrue(spec.meaning.isNotBlank(), "${spec.code} has no meaning")
|
||||||
|
assertTrue(
|
||||||
|
spec.meaning.trimEnd().endsWith("."),
|
||||||
|
"${spec.code}'s meaning should be a sentence: '${spec.meaning}'",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two near-identical codes are how one fault ends up split across two dashboards. This is a
|
||||||
|
// blunt check — it will not catch every synonym — but it catches the shape that already
|
||||||
|
// happened: the same words in a different order.
|
||||||
|
@Test
|
||||||
|
fun noTwoCodesAreAnagramsOfEachOther() {
|
||||||
|
val normalised = FindingRegistry.all.associate { spec ->
|
||||||
|
spec.code to spec.code.substringAfter('.').split('_').sorted().joinToString("_")
|
||||||
|
}
|
||||||
|
val clashes = normalised.entries.groupBy { it.value }.filterValues { it.size > 1 }
|
||||||
|
if (clashes.isNotEmpty()) {
|
||||||
|
fail("codes differing only in word order: ${clashes.values.map { g -> g.map { it.key } }}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDocumentAndTheRegistryAgree() {
|
||||||
|
val doc = findDoc() ?: run {
|
||||||
|
println("findings-registry.md not found from ${File(".").absolutePath} — skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text = doc.readText()
|
||||||
|
|
||||||
|
// Only table rows count as "documented". Prose may legitimately mention a code that no
|
||||||
|
// longer exists — the rules section explains why two were merged — and treating that as
|
||||||
|
// a registry entry would force the document to forget its own history.
|
||||||
|
val documented = text.lines()
|
||||||
|
.filter { it.trimStart().startsWith("|") }
|
||||||
|
.flatMap { row -> Regex("`([a-z0-9]+\\.[a-z0-9_]+)`").findAll(row).map { it.groupValues[1] } }
|
||||||
|
.toSet()
|
||||||
|
val registered = FindingRegistry.all.map { it.code }.toSet()
|
||||||
|
|
||||||
|
val missingFromDoc = registered - documented
|
||||||
|
val missingFromCode = documented - registered
|
||||||
|
assertTrue(
|
||||||
|
missingFromDoc.isEmpty(),
|
||||||
|
"these codes exist in FindingRegistry but not in docs/findings-registry.md: $missingFromDoc",
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
missingFromCode.isEmpty(),
|
||||||
|
"docs/findings-registry.md documents codes that no longer exist: $missingFromCode",
|
||||||
|
)
|
||||||
|
|
||||||
|
// And the severities must match, or the document is describing a different system.
|
||||||
|
for (spec in FindingRegistry.all) {
|
||||||
|
val row = text.lines().firstOrNull {
|
||||||
|
it.trimStart().startsWith("|") && it.contains("`${spec.code}`")
|
||||||
|
} ?: continue
|
||||||
|
val severity = spec.severity.name.lowercase()
|
||||||
|
assertTrue(
|
||||||
|
row.contains("| $severity |"),
|
||||||
|
"${spec.code} is ${severity} in code but the doc row says otherwise: $row",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walks up from the test's working directory to find the repo's docs/ folder. */
|
||||||
|
private fun findDoc(): File? {
|
||||||
|
var dir: File? = File(".").absoluteFile
|
||||||
|
repeat(6) {
|
||||||
|
val candidate = File(dir, "docs/findings-registry.md")
|
||||||
|
if (candidate.isFile) return candidate
|
||||||
|
dir = dir?.parentFile
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,125 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fixtures here are a real device's routing table, transcribed from `dumpsys connectivity`
|
||||||
|
* on a OnePlus 15 with a Netbird tunnel up: wifi advertising a default route it cannot source
|
||||||
|
* from, cellular working properly, and a VPN carrying host routes to two destinations.
|
||||||
|
*
|
||||||
|
* Using a captured table rather than invented ones matters, because the bug this guards against
|
||||||
|
* is not "the boolean logic is wrong" — it is "the shapes I imagined are not the shapes real
|
||||||
|
* networks produce".
|
||||||
|
*/
|
||||||
|
class V6AnalysisTest {
|
||||||
|
|
||||||
|
private fun net(
|
||||||
|
id: String,
|
||||||
|
transport: Transport,
|
||||||
|
iface: String,
|
||||||
|
addrs: List<String>,
|
||||||
|
routes: List<Pair<String, String>>,
|
||||||
|
) = Network(
|
||||||
|
id = id,
|
||||||
|
transport = transport,
|
||||||
|
iface = iface,
|
||||||
|
link = Link(
|
||||||
|
addresses = addrs.map { Address(addr = it.substringBefore('/'), prefixLen = 64) },
|
||||||
|
routes = routes.map { (dst, dev) -> Route(dst = dst, iface = dev) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** wlan0: an IPv6 default route via a link-local gateway, but SLAAC produced no address. */
|
||||||
|
private val wifi = net(
|
||||||
|
"w", Transport.WIFI, "wlan0",
|
||||||
|
addrs = listOf("fe80::bcf6:edff:fe67:b139", "10.13.102.122"),
|
||||||
|
routes = listOf(
|
||||||
|
"fe80::/64" to "wlan0",
|
||||||
|
"::/0" to "wlan0",
|
||||||
|
"0.0.0.0/0" to "wlan0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** rmnet_data1: a properly configured cellular link — global address and a default route. */
|
||||||
|
private val cellular = net(
|
||||||
|
"c", Transport.CELLULAR, "rmnet_data1",
|
||||||
|
addrs = listOf("2001:4bb8:46a:e724:289d:87ff:feb6:ebd3"),
|
||||||
|
routes = listOf("::/0" to "rmnet_data1", "2001:4bb8:46a:e724::/64" to "rmnet_data1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** tun1: Netbird, with a ULA and host routes to exactly two destinations. */
|
||||||
|
private val vpn = net(
|
||||||
|
"v", Transport.VPN, "tun1",
|
||||||
|
addrs = listOf("100.64.158.131", "fdfd:c4fe:c4fe:c4fe:1f3c:98a0:dd66:ac7"),
|
||||||
|
routes = listOf(
|
||||||
|
"2001:1ad0:c4fe:6767::2/128" to "tun1",
|
||||||
|
"2001:1ad0:c4fe:a::136/128" to "tun1",
|
||||||
|
"fdfd:c4fe:c4fe:c4fe::/64" to "tun1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wifi advertising a route it cannot source from is reported`() {
|
||||||
|
val s = V6Analysis.classify(listOf(wifi)).single()
|
||||||
|
assertTrue(s.routeWithoutAddress, "::/0 with only a link-local address is the RA-without-SLAAC case")
|
||||||
|
assertFalse(s.addressWithoutRoute)
|
||||||
|
assertFalse(s.tunnel, "wifi is not a tunnel")
|
||||||
|
assertEquals("wlan0", s.iface)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a properly configured link produces no finding`() {
|
||||||
|
val s = V6Analysis.classify(listOf(cellular)).single()
|
||||||
|
assertFalse(s.routeWithoutAddress)
|
||||||
|
assertFalse(s.addressWithoutRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a tunnel with host routes is deliberate, not broken`() {
|
||||||
|
val s = V6Analysis.classify(listOf(vpn)).single()
|
||||||
|
assertTrue(s.addressWithoutRoute, "a ULA and no ::/0 is an address with nothing to route it")
|
||||||
|
assertTrue(s.tunnel, "so it must be reported as information, not as a fault")
|
||||||
|
assertFalse(s.routeWithoutAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `each network is judged on its own`() {
|
||||||
|
// The whole point of per-network classification: "IPv6 is broken" is useless advice when
|
||||||
|
// wifi is the broken one and cellular is fine.
|
||||||
|
val shapes = V6Analysis.classify(listOf(wifi, cellular, vpn)).associateBy { it.iface }
|
||||||
|
assertTrue(shapes.getValue("wlan0").routeWithoutAddress)
|
||||||
|
assertFalse(shapes.getValue("rmnet_data1").routeWithoutAddress)
|
||||||
|
assertFalse(shapes.getValue("rmnet_data1").addressWithoutRoute)
|
||||||
|
assertTrue(shapes.getValue("tun1").addressWithoutRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a link-local-only network with no v6 route says nothing either way`() {
|
||||||
|
// Plain IPv4-only wifi: no IPv6 offered at all. That is v6.not_offered's business, and
|
||||||
|
// reporting it here as well would double up on a network that is merely legacy, not broken.
|
||||||
|
val v4only = net(
|
||||||
|
"4", Transport.WIFI, "wlan0",
|
||||||
|
addrs = listOf("fe80::1", "192.168.1.5"),
|
||||||
|
routes = listOf("0.0.0.0/0" to "wlan0"),
|
||||||
|
)
|
||||||
|
val s = V6Analysis.classify(listOf(v4only)).single()
|
||||||
|
assertFalse(s.routeWithoutAddress)
|
||||||
|
assertFalse(s.addressWithoutRoute)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a zone index does not hide a link-local address`() {
|
||||||
|
val zoned = net(
|
||||||
|
"z", Transport.WIFI, "wlan0",
|
||||||
|
addrs = listOf("fe80::1%wlan0"),
|
||||||
|
routes = listOf("::/0" to "wlan0"),
|
||||||
|
)
|
||||||
|
assertTrue(V6Analysis.classify(listOf(zoned)).single().routeWithoutAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
// 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()
|
||||||
|
// Opt-in: point this at a captured run to check the anonymizer against real data.
|
||||||
|
System.getenv("ECHOLOT_REAL_RUN")?.let { environment("ECHOLOT_REAL_RUN", it) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// 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) {
|
||||||
|
// Name first (it is precise), then shape (it is exhaustive). A field nobody
|
||||||
|
// classified must not be a field that leaks.
|
||||||
|
val type = Classification.typeOf(key, path) ?: Classification.inferFromValue(v.content)
|
||||||
|
JsonPrimitive(transform(type, v.content))
|
||||||
|
} else {
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||||
|
// Unclassified strings still get their *embedded* identifiers scrubbed. A whole-value
|
||||||
|
// check cannot see them: raw shell output is one long string that is neither a MAC nor an
|
||||||
|
// address, so it sailed through both the name table and the shape check carrying every
|
||||||
|
// MAC on the user's LAN.
|
||||||
|
null -> scrubEmbedded(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]"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces addresses and MACs found *inside* a longer string.
|
||||||
|
*
|
||||||
|
* Shizuku probes embed raw command output verbatim — `ip neigh`, `ip route`, `dumpsys` — which
|
||||||
|
* is genuinely valuable evidence and also a complete inventory of every device on the user's
|
||||||
|
* network, with hardware addresses. measurement-schema.md §9 flagged these as "hard to
|
||||||
|
* anonymize" and proposed dropping them from exports.
|
||||||
|
*
|
||||||
|
* Scrubbing beats dropping: the output stays readable and auditable — you can still see the
|
||||||
|
* shape of the neighbour table and how many hosts there were — while the identifiers become
|
||||||
|
* the same pseudonyms used everywhere else in the document. So a MAC appearing both in a
|
||||||
|
* parsed field and in a raw dump still reads as one device.
|
||||||
|
*
|
||||||
|
* Only addresses and MACs are touched, for the same reason as [Classification.inferFromValue]:
|
||||||
|
* they are the patterns that cannot be mistaken for something else in free text.
|
||||||
|
*/
|
||||||
|
private fun scrubEmbedded(value: String): String {
|
||||||
|
// Cheap bail-out: the overwhelming majority of strings are short and contain neither.
|
||||||
|
if (value.length < 7 || (!value.contains(':') && !value.contains('.'))) return value
|
||||||
|
// One pass, not three. Sequential passes re-process their own output: after a MAC became
|
||||||
|
// 78:9a:18:xx:yy:zz the IPv6 pattern matched it — six hex groups separated by colons is
|
||||||
|
// exactly an address — and mangled the vendor prefix that the MAC rule had just taken
|
||||||
|
// care to preserve. Ordered alternation resolves each position once, MAC first.
|
||||||
|
return EMBEDDED.replace(value) { m ->
|
||||||
|
when {
|
||||||
|
m.groups[1] != null -> macPreservingOui(m.value)
|
||||||
|
m.groups[2] != null -> ip6(m.value)
|
||||||
|
else -> ip4(m.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 {
|
||||||
|
// A route destination carries a prefix length; pseudonymize the address and put it back,
|
||||||
|
// or "0.0.0.0/0" turns into nonsense and the routing table becomes unreadable.
|
||||||
|
value.substringAfter('/', "").takeIf { it.isNotEmpty() && value.contains('/') }?.let { len ->
|
||||||
|
return ip4(value.substringBefore('/')) + "/" + len
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
// Dotted quads reach here through the family-agnostic field names (addr, gateway, dst);
|
||||||
|
// hand them to the IPv4 path rather than mangling them as if they were v6.
|
||||||
|
if (value.count { it == ':' } < 2) return ip4(value)
|
||||||
|
if (value.contains('/')) {
|
||||||
|
return ip6(value.substringBefore('/')) + "/" + value.substringAfter('/')
|
||||||
|
}
|
||||||
|
val v = value.lowercase(Locale.ROOT)
|
||||||
|
// The unspecified address and the default route are not identities; mangling them would
|
||||||
|
// make a routing table unreadable for no privacy gain.
|
||||||
|
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
|
||||||
|
|
||||||
|
// Unique local addresses (fc00::/7) need the *whole* prefix replaced, not the tail.
|
||||||
|
//
|
||||||
|
// They look like the v6 equivalent of RFC1918, and the first instinct is to keep them for
|
||||||
|
// the same reason: private, topological, says nothing about anyone. That reasoning does
|
||||||
|
// not carry over. An RFC1918 prefix is shared by millions of networks and identifies
|
||||||
|
// none of them; a ULA global ID is 40 *random* bits, unique to one network by
|
||||||
|
// construction (RFC 4193). It is a network fingerprint. Passing the leading groups
|
||||||
|
// through - which is what the general path does - leaked 32 of those 40 bits.
|
||||||
|
//
|
||||||
|
// The prefix is pseudonymized as a unit, so two addresses on the same ULA subnet still
|
||||||
|
// land on the same pseudonymous prefix. "These hosts are on one network" survives;
|
||||||
|
// "this is *that* network" does not.
|
||||||
|
if (v.startsWith("fc") || v.startsWith("fd")) {
|
||||||
|
val groups = v.substringBefore('%').split(":")
|
||||||
|
val prefix = pseudo("ula-prefix", groups.take(3).joinToString(":")) { it }
|
||||||
|
val host = pseudo("ula-host", v) { it }
|
||||||
|
return "fd${prefix.substring(0, 2)}:${prefix.substring(2, 6)}:${prefix.substring(6, 10)}" +
|
||||||
|
"::${host.substring(0, 4)}"
|
||||||
|
}
|
||||||
|
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\u0000$value") {
|
||||||
|
val md = MessageDigest.getInstance("SHA-256")
|
||||||
|
md.update(salt.bytes)
|
||||||
|
md.update(domain.toByteArray())
|
||||||
|
md.update(0)
|
||||||
|
md.update(value.lowercase(Locale.ROOT).toByteArray())
|
||||||
|
shape(md.digest().joinToString("") { "%02x".format(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* MAC | IPv6 | IPv4, in that order — alternation is ordered, so a MAC-shaped token is
|
||||||
|
* claimed by the MAC rule before the IPv6 rule can see it.
|
||||||
|
*
|
||||||
|
* The patterns are deliberately conservative. A missed address is scrubbed by another
|
||||||
|
* rule or not at all; an over-eager one mangles timestamps, version strings and log
|
||||||
|
* prefixes, corrupting evidence to protect nothing.
|
||||||
|
*/
|
||||||
|
val EMBEDDED = Regex(
|
||||||
|
// Raw strings: a regex written with escaped escapes is a regex nobody can check.
|
||||||
|
"""(\b[0-9a-fA-F]{2}(?:[:-][0-9a-fA-F]{2}){5}\b)""" +
|
||||||
|
"""|(\b(?:[0-9a-fA-F]{1,4}:){2,7}(?::|[0-9a-fA-F]{1,4})(?:[0-9a-fA-F:]*))""" +
|
||||||
|
"""|(\b(?:\d{1,3}\.){3}\d{1,3}\b)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
val publicSuffixes = setOf(
|
||||||
|
"local", "lan", "home", "internal", "arpa",
|
||||||
|
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.privacy
|
||||||
|
|
||||||
|
/** The logical types of measurement-schema.md §8. */
|
||||||
|
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
|
||||||
|
*
|
||||||
|
* This is a table on purpose. The alternative — annotating the Kotlin models and reflecting over
|
||||||
|
* them — spreads the answer across every module and makes "what exactly gets uploaded?" a
|
||||||
|
* question you answer by reading the whole app. Here it is one file a reviewer can check against
|
||||||
|
* the spec in a sitting, and a new field that nobody classified stays visible in the output
|
||||||
|
* rather than being silently mangled.
|
||||||
|
*
|
||||||
|
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
|
||||||
|
* an SSID wrongly kept is a leak nobody notices.
|
||||||
|
*/
|
||||||
|
object Classification {
|
||||||
|
|
||||||
|
private val byKey: Map<String, LogicalType> = buildMap {
|
||||||
|
listOf(
|
||||||
|
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
|
||||||
|
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
|
||||||
|
).forEach { put(it, LogicalType.IP4) }
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
|
||||||
|
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
|
||||||
|
"link_local", "ra_source", "prefix",
|
||||||
|
).forEach { put(it, LogicalType.IP6) }
|
||||||
|
|
||||||
|
// Family-agnostic address fields — the names the models actually use (Address.addr,
|
||||||
|
// Route.gateway, Route.dst, DnsConfig.servers). Their absence here was a real leak: the
|
||||||
|
// device's own global IPv6 address went out verbatim at the level whose description
|
||||||
|
// promises addresses are pseudonymized. Typed IP6 because the transform detects the
|
||||||
|
// family from the value, falling through to the IPv4 path for a dotted quad.
|
||||||
|
listOf(
|
||||||
|
"addr", "address", "gateway", "dst", "src", "servers", "server", "resolver",
|
||||||
|
"next_hop", "via", "public_ip", "observed_ip",
|
||||||
|
).forEach { put(it, LogicalType.IP6) }
|
||||||
|
|
||||||
|
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
|
||||||
|
.forEach { put(it, LogicalType.MAC) }
|
||||||
|
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
|
||||||
|
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
|
||||||
|
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
|
||||||
|
// Plural and prefixed variants the models actually use.
|
||||||
|
"search_domains", "private_dns_hostname", "domains", "hostnames",
|
||||||
|
).forEach { put(it, LogicalType.FQDN) }
|
||||||
|
|
||||||
|
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
|
||||||
|
.forEach { put(it, LogicalType.OPAQUE_ID) }
|
||||||
|
|
||||||
|
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
|
||||||
|
.forEach { put(it, LogicalType.FREETEXT) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole subtrees that BALANCED removes rather than pseudonymizes.
|
||||||
|
*
|
||||||
|
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
|
||||||
|
* case: they describe other people's devices, they are a household fingerprint even with the
|
||||||
|
* names hashed, and no metric depends on them. Dropping beats mangling.
|
||||||
|
*/
|
||||||
|
private val droppedPaths: List<List<String>> = listOf(
|
||||||
|
listOf("networks", "neighbors"),
|
||||||
|
listOf("networks", "arp"),
|
||||||
|
listOf("networks", "wifi", "scan_results"),
|
||||||
|
listOf("run", "device", "security_patch"),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
|
||||||
|
private val droppedKeys = setOf(
|
||||||
|
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
|
||||||
|
"nearby_networks", "peers", "raw_dump", "dumpsys",
|
||||||
|
)
|
||||||
|
|
||||||
|
fun typeOf(key: String, path: List<String>): LogicalType? {
|
||||||
|
byKey[key]?.let { return it }
|
||||||
|
// Inside a discovery/neighbour container every string is someone's device name until
|
||||||
|
// proven otherwise, so classify unknown strings there as free text rather than passing
|
||||||
|
// them through.
|
||||||
|
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Last-resort classification from the *value*, when the field name is unrecognised.
|
||||||
|
*
|
||||||
|
* A name table can only protect fields somebody remembered to add, which is the wrong
|
||||||
|
* property for a privacy control: the dangerous field is the one nobody thought of. This
|
||||||
|
* exists because that failed once already — `addresses[].addr` holds the device's own global
|
||||||
|
* IPv6 address, the table had never heard of the name, and it went out verbatim.
|
||||||
|
*
|
||||||
|
* Only addresses and MACs are inferred, because only those have shapes that cannot be
|
||||||
|
* mistaken for something else. Hostnames deliberately are not: `train.udp_updown` is
|
||||||
|
* indistinguishable from a domain by shape, and mangling a test type would corrupt the
|
||||||
|
* document to protect nothing.
|
||||||
|
*/
|
||||||
|
fun inferFromValue(value: String): LogicalType? {
|
||||||
|
val v = value.trim()
|
||||||
|
if (v.isEmpty() || v.length > 64) return null
|
||||||
|
if (looksLikeMac(v)) return LogicalType.MAC
|
||||||
|
if (looksLikeIp6(v)) return LogicalType.IP6
|
||||||
|
if (looksLikeIp4(v)) return LogicalType.IP4
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
|
||||||
|
|
||||||
|
private fun looksLikeMac(v: String): Boolean {
|
||||||
|
val parts = v.split(':', '-')
|
||||||
|
return parts.size == 6 && parts.all { p -> p.length == 2 && p.all(::isHex) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun looksLikeIp4(v: String): Boolean {
|
||||||
|
val parts = v.substringBefore('/').split('.')
|
||||||
|
return parts.size == 4 && parts.all { p ->
|
||||||
|
p.isNotEmpty() && p.length <= 3 && p.all(Char::isDigit) && p.toInt() <= 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun looksLikeIp6(v: String): Boolean {
|
||||||
|
val core = v.substringBefore('/').substringBefore('%')
|
||||||
|
// Two colons minimum, so a time or a MAC fragment does not qualify, and nothing but the
|
||||||
|
// characters an address may contain.
|
||||||
|
return core.count { it == ':' } >= 2 && core.all { it == ':' || isHex(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dropAtBalanced(path: List<String>): Boolean {
|
||||||
|
if (path.isNotEmpty() && path.last() in droppedKeys) return true
|
||||||
|
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.privacy
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These tests are the audit of the anonymizer: each one states a property someone's privacy
|
||||||
|
* depends on, so a regression here fails loudly rather than quietly leaking.
|
||||||
|
*/
|
||||||
|
class AnonymizerTest {
|
||||||
|
|
||||||
|
private val json = Json { prettyPrint = false }
|
||||||
|
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
|
||||||
|
|
||||||
|
private fun sample(): JsonObject = json.parseToJsonElement(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"schema": "echolot/measurement",
|
||||||
|
"run": {
|
||||||
|
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
|
||||||
|
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
|
||||||
|
},
|
||||||
|
"networks": [{
|
||||||
|
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
|
||||||
|
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
|
||||||
|
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
|
||||||
|
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
|
||||||
|
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
|
||||||
|
}],
|
||||||
|
"tests": [{
|
||||||
|
"id": "t1", "type": "train.udp_updown", "status": "ok",
|
||||||
|
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
|
||||||
|
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
|
||||||
|
}],
|
||||||
|
"findings": [{
|
||||||
|
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
|
||||||
|
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
|
||||||
|
}],
|
||||||
|
"summary": {"verdict": "warn"}
|
||||||
|
}
|
||||||
|
""".trimIndent(),
|
||||||
|
).jsonObject
|
||||||
|
|
||||||
|
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
|
||||||
|
|
||||||
|
private fun flat(e: JsonElement): String = e.toString()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fullLeavesTheDocumentAloneButRecordsThat() {
|
||||||
|
val out = anon(PrivacyLevel.FULL)
|
||||||
|
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun balancedRemovesTheSsidAndTheNotes() {
|
||||||
|
val text = flat(anon(PrivacyLevel.BALANCED))
|
||||||
|
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
|
||||||
|
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun balancedDropsNeighbourInventoriesEntirely() {
|
||||||
|
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
|
||||||
|
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
|
||||||
|
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun balancedKeepsTheVendorHalfOfAMac() {
|
||||||
|
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
|
||||||
|
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
|
||||||
|
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
|
||||||
|
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
|
||||||
|
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
|
||||||
|
"RFC1918 says nothing about the user and everything about the topology")
|
||||||
|
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
|
||||||
|
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
|
||||||
|
"the /16 should survive for ISP-level context")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun linkLocalIsKeptButGlobalV6IsNot() {
|
||||||
|
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
|
||||||
|
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
|
||||||
|
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metricsAndVerdictsAreNeverTouched() {
|
||||||
|
for (level in PrivacyLevel.entries) {
|
||||||
|
val out = anon(level)
|
||||||
|
val t = out["tests"]!!.jsonArray[0].jsonObject
|
||||||
|
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
|
||||||
|
"$level changed a metric")
|
||||||
|
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
|
||||||
|
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
|
||||||
|
for (level in PrivacyLevel.entries) {
|
||||||
|
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
|
||||||
|
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
|
||||||
|
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun strictDropsEvidenceAndProse() {
|
||||||
|
val out = anon(PrivacyLevel.STRICT)
|
||||||
|
assertNull(out["networks"], "STRICT should not describe the network at all")
|
||||||
|
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
|
||||||
|
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
|
||||||
|
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pseudonymsAreConsistentWithinADocument() {
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
|
||||||
|
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
|
||||||
|
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
|
||||||
|
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
|
||||||
|
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
|
||||||
|
assertNotEquals(a, c, "different SSIDs must not collide")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
|
||||||
|
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
|
||||||
|
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
|
||||||
|
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
|
||||||
|
assertNotEquals(
|
||||||
|
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
|
||||||
|
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
|
||||||
|
"a per-run salt must not produce a cross-run tracking identifier",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
|
||||||
|
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
|
||||||
|
val secret = ByteArray(32) { 7 }
|
||||||
|
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
|
||||||
|
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
|
||||||
|
assertEquals(
|
||||||
|
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
|
||||||
|
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
|
||||||
|
)
|
||||||
|
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDeclaredLevelMatchesWhatWasApplied() {
|
||||||
|
for (level in PrivacyLevel.entries) {
|
||||||
|
assertEquals(
|
||||||
|
level.wire,
|
||||||
|
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverMinimumWins() {
|
||||||
|
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
|
||||||
|
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
|
||||||
|
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ULA looks like the v6 RFC1918 and is not. Its global ID is 40 random bits, unique to one
|
||||||
|
// network by construction (RFC 4193), so the prefix IS the identifier - unlike 192.168.x,
|
||||||
|
// which millions of networks share. Passing the leading groups through leaked most of it.
|
||||||
|
@Test
|
||||||
|
fun ulaPrefixesArePseudonymizedWhole() {
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["fda1:3fb1:ff92:6696::2662"]}}}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val out = flat(anon(PrivacyLevel.BALANCED, doc))
|
||||||
|
assertFalse(out.contains("fda1"), "the ULA global ID survived: $out")
|
||||||
|
assertFalse(out.contains("3fb1"), "part of the ULA global ID survived: $out")
|
||||||
|
assertTrue(out.contains("fd"), "the result should still read as a ULA: $out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pseudonymizing the prefix as a unit keeps the one fact that is diagnostically useful:
|
||||||
|
// whether two addresses sit on the same network.
|
||||||
|
@Test
|
||||||
|
fun addressesOnOneUlaSubnetStayRelated() {
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":[
|
||||||
|
"fda1:3fb1:ff92:6696::1","fda1:3fb1:ff92:6696::2","fdff:9999:8888:7777::1"]}}}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val servers = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray[0].jsonObject["link"]!!
|
||||||
|
.jsonObject["dns"]!!.jsonObject["servers"]!!.jsonArray.map { it.jsonPrimitive.content }
|
||||||
|
val prefixOf = { s: String -> s.substringBeforeLast("::") }
|
||||||
|
assertEquals(prefixOf(servers[0]), prefixOf(servers[1]),
|
||||||
|
"two addresses on one ULA subnet should share a pseudonymous prefix")
|
||||||
|
assertNotEquals(prefixOf(servers[0]), prefixOf(servers[2]),
|
||||||
|
"a different ULA network must not collide with the first")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC1918 stays readable, and this is the contrast that justifies it: a shared, meaningless
|
||||||
|
// prefix is topology; a unique random one is identity.
|
||||||
|
@Test
|
||||||
|
fun rfc1918StaysReadableUnlikeUla() {
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},"networks":[{"link":{"dns":{"servers":["192.168.1.1","10.13.102.1"]}}}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val out = flat(anon(PrivacyLevel.BALANCED, doc))
|
||||||
|
assertTrue(out.contains("192.168.1.1"), "RFC1918 should survive: $out")
|
||||||
|
assertTrue(out.contains("10.13.102.1"), "RFC1918 should survive: $out")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.privacy
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The blunt instrument: build a document with identifying values in every place one can actually
|
||||||
|
* occur, anonymize it, and assert none of them survive.
|
||||||
|
*
|
||||||
|
* [AnonymizerTest] checks that the fields the classification table knows about are handled
|
||||||
|
* correctly. This checks the other half — the fields it does *not* know about. A per-field test
|
||||||
|
* can only fail for a field someone remembered to write a case for, which is exactly the wrong
|
||||||
|
* property for a privacy check: the dangerous field is the one nobody thought of.
|
||||||
|
*
|
||||||
|
* Concretely, this is written the way it is because the schema's own field names disagree with
|
||||||
|
* the classifier's. `Address.addr` carries an IP and is documented as such in
|
||||||
|
* measurement-schema.md §8, but the classifier keys on names like `ip4` and `gateway_ip4` and had
|
||||||
|
* never heard of `addr`.
|
||||||
|
*/
|
||||||
|
class LeakTest {
|
||||||
|
|
||||||
|
private val json = Json { prettyPrint = false }
|
||||||
|
private val salt = Salt.perRun(ByteArray(32) { 3 })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every string here is something that identifies a person, a household or a device, placed
|
||||||
|
* where the real models actually put it (`core-measurement`'s Network/Link/Address/DnsConfig).
|
||||||
|
*/
|
||||||
|
private val secrets = listOf(
|
||||||
|
"Rambossek WLAN", // ssid
|
||||||
|
"78:9a:18:aa:bb:cc", // bssid
|
||||||
|
"aa:bb:cc:dd:ee:11", // gateway mac
|
||||||
|
"2001:1ad0:c4fe:6767::150", // global v6 address on the interface
|
||||||
|
"2a02:1748:dead:beef::1", // v6 default gateway
|
||||||
|
"203.0.113.77", // public v4
|
||||||
|
"nas.rambossek.lan", // private-dns hostname
|
||||||
|
"rambossek.lan", // search domain
|
||||||
|
"Anna's Chromecast", // neighbour name
|
||||||
|
"kitchen table", // free-text note
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun document(): String = """
|
||||||
|
{
|
||||||
|
"schema": "echolot/measurement",
|
||||||
|
"run": {
|
||||||
|
"id": "run-1", "trigger": "manual", "notes": "${secrets[9]}",
|
||||||
|
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
|
||||||
|
},
|
||||||
|
"networks": [{
|
||||||
|
"id": "net-1", "transport": "wifi",
|
||||||
|
"link": {
|
||||||
|
"mtu": 1500,
|
||||||
|
"addresses": [
|
||||||
|
{"addr": "${secrets[3]}", "prefix_len": 64, "scope": "global"},
|
||||||
|
{"addr": "192.168.1.44", "prefix_len": 24, "scope": "global"}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{"dst": "::/0", "gateway": "${secrets[4]}", "iface": "wlan0"},
|
||||||
|
{"dst": "0.0.0.0/0", "gateway": "192.168.1.1", "iface": "wlan0"}
|
||||||
|
],
|
||||||
|
"dns": {
|
||||||
|
"servers": ["${secrets[5]}", "192.168.1.1"],
|
||||||
|
"private_dns_hostname": "${secrets[6]}",
|
||||||
|
"search_domains": ["${secrets[7]}"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wifi": {"ssid": "${secrets[0]}", "bssid": "${secrets[1]}"},
|
||||||
|
"neighbors": [{"name": "${secrets[8]}", "mac": "${secrets[2]}"}]
|
||||||
|
}],
|
||||||
|
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
|
||||||
|
"metrics": {"rtt_ms_avg": 12.4}}],
|
||||||
|
"findings": [],
|
||||||
|
"summary": {"verdict": "ok"}
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
private fun anonymized(level: PrivacyLevel): String =
|
||||||
|
json.encodeToString(
|
||||||
|
kotlinx.serialization.json.JsonObject.serializer(),
|
||||||
|
Anonymizer(level, salt).anonymize(json.parseToJsonElement(document()).jsonObject),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nothingIdentifyingSurvivesBalanced() {
|
||||||
|
val out = anonymized(PrivacyLevel.BALANCED)
|
||||||
|
val leaked = secrets.filter { out.contains(it) }
|
||||||
|
assertTrue(
|
||||||
|
leaked.isEmpty(),
|
||||||
|
"these identifying values were uploaded verbatim at BALANCED: $leaked\n\n$out",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nothingIdentifyingSurvivesStrict() {
|
||||||
|
val out = anonymized(PrivacyLevel.STRICT)
|
||||||
|
val leaked = secrets.filter { out.contains(it) }
|
||||||
|
assertTrue(leaked.isEmpty(), "leaked at STRICT: $leaked\n\n$out")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private addresses are kept on purpose — they describe the topology and not the person — so
|
||||||
|
// this pins that the leak test above is not passing by accident of over-redaction.
|
||||||
|
@Test
|
||||||
|
fun privateAddressesAreStillReadable() {
|
||||||
|
val out = anonymized(PrivacyLevel.BALANCED)
|
||||||
|
assertTrue(out.contains("192.168.1.1"), "RFC1918 gateway should survive: $out")
|
||||||
|
assertTrue(out.contains("192.168.1.44"), "RFC1918 interface address should survive: $out")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw shell output embeds a complete inventory of the local network, and neither the field-name
|
||||||
|
* table nor the whole-value shape check can see it: `ip_neigh` is one long string that is
|
||||||
|
* itself neither a MAC nor an address.
|
||||||
|
*
|
||||||
|
* This is not hypothetical. The blob below is (abridged) real output that reached the server
|
||||||
|
* at the `balanced` level from a test device, carrying the hardware address of every host on
|
||||||
|
* the network. measurement-schema.md §9 had flagged raw dumps as "hard to anonymize"; nothing
|
||||||
|
* enforced it.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun identifiersInsideRawShellOutputAreScrubbed() {
|
||||||
|
// Joined rather than written with escapes, so the fixture stays readable and there is no
|
||||||
|
// chance of an escape being mangled on its way into the JSON below.
|
||||||
|
val dump = listOf(
|
||||||
|
"uid=2000",
|
||||||
|
"10.13.102.5 dev wlan0 lladdr 90:09:d0:1a:83:e4 REACHABLE",
|
||||||
|
"10.13.102.1 dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE",
|
||||||
|
"10.13.102.111 dev wlan0 lladdr dc:a2:66:08:69:95 STALE",
|
||||||
|
"2001:4bb8:46a:e724:289d:87ff:feb6:ebd3 dev wlan0 lladdr b8:be:f4:bc:ca:cf STALE",
|
||||||
|
).joinToString(" | ")
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},"tests":[{"id":"t","type":"link.ip_monitor",
|
||||||
|
"evidence":{"ip_neigh":"$dump"}}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val out = json.encodeToString(
|
||||||
|
kotlinx.serialization.json.JsonObject.serializer(),
|
||||||
|
Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (mac in listOf("90:09:d0:1a:83:e4", "78:9a:18:54:b8:f9", "dc:a2:66:08:69:95", "b8:be:f4:bc:ca:cf")) {
|
||||||
|
assertFalse(out.contains(mac), "a neighbour's MAC survived inside the raw dump: $mac")
|
||||||
|
}
|
||||||
|
assertFalse(out.contains("2001:4bb8:46a:e724:289d:87ff:feb6:ebd3"),
|
||||||
|
"a global IPv6 survived inside the raw dump")
|
||||||
|
|
||||||
|
// Scrubbed, not dropped: the evidence must still be readable, or the raw dump stops being
|
||||||
|
// evidence at all. Structure, hostnames of the fields, and RFC1918 addresses stay.
|
||||||
|
assertTrue(out.contains("REACHABLE") && out.contains("STALE"), "the dump lost its structure")
|
||||||
|
assertTrue(out.contains("10.13.102.1"), "RFC1918 addresses should stay readable: $out")
|
||||||
|
assertTrue(out.contains("78:9a:18"), "the vendor prefix should survive for identification")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A MAC in a raw dump and the same MAC in a parsed field must land on the same pseudonym, or
|
||||||
|
// the document stops being internally consistent and one device reads as two.
|
||||||
|
@Test
|
||||||
|
fun theSameIdentifierMatchesAcrossParsedAndRawFields() {
|
||||||
|
val doc = json.parseToJsonElement(
|
||||||
|
"""{"run":{"id":"r"},
|
||||||
|
"networks":[{"wifi":{"bssid":"78:9a:18:54:b8:f9"}}],
|
||||||
|
"tests":[{"id":"t","evidence":{"ip_neigh":"gw dev wlan0 lladdr 78:9a:18:54:b8:f9 REACHABLE"}}]}"""
|
||||||
|
).jsonObject
|
||||||
|
val out = Anonymizer(PrivacyLevel.BALANCED, salt).anonymize(doc)
|
||||||
|
val parsed = out["networks"]!!.jsonArray[0].jsonObject["wifi"]!!.jsonObject["bssid"]!!
|
||||||
|
.jsonPrimitive.content
|
||||||
|
val raw = json.encodeToString(kotlinx.serialization.json.JsonObject.serializer(), out)
|
||||||
|
assertTrue(raw.contains(parsed),
|
||||||
|
"the parsed BSSID pseudonym ($parsed) does not appear in the scrubbed dump")
|
||||||
|
}
|
||||||
|
}
|
||||||