Compare commits
63
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 }}
|
||||||
|
|||||||
@@ -145,6 +145,17 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
|
|||||||
Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh
|
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
|
port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
|
||||||
around this (or USB, if ever available).
|
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
|
- **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
|
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
|
containing only a manifest, and Gradle then considers `jar` up-to-date. Dependent modules fail
|
||||||
|
|||||||
+549
-3
@@ -211,8 +211,8 @@ Two collection-loop gotchas found while driving the phone over USB:
|
|||||||
2. ~~If `trace.errqueue_reachable` = PARTIAL, add a C-over-JNI errqueue shim.~~ **Retired** —
|
2. ~~If `trace.errqueue_reachable` = PARTIAL, add a C-over-JNI errqueue shim.~~ **Retired** —
|
||||||
SUPPORTED on both known devices; `traceroute.udp4` reads real hops via `Os.recvmsg` +
|
SUPPORTED on both known devices; `traceroute.udp4` reads real hops via `Os.recvmsg` +
|
||||||
`StructMsghdr` reflection, so no `:native` module is needed.
|
`StructMsghdr` reflection, so no `:native` module is needed.
|
||||||
3. Start the Go server skeleton (enrollment + profile + sessions + UDP echo with observation
|
3. ~~Start the Go server skeleton per probe-protocol.md.~~ **Shipped** — live on fmr since
|
||||||
blocks + canary-DNS reference records) per probe-protocol.md.
|
v0.2.0 (2026-07-31); see the server sections below.
|
||||||
4. Fold confirmed capabilities into the production `core-probe` / `core-shizuku` modules.
|
4. Fold confirmed capabilities into the production `core-probe` / `core-shizuku` modules.
|
||||||
|
|
||||||
## Production probe server — LIVE on dedicated VM "fmr" (2026-07-31)
|
## Production probe server — LIVE on dedicated VM "fmr" (2026-07-31)
|
||||||
@@ -222,7 +222,7 @@ SSH only — verified untouched by the daemon (explicit multi-address binds, no
|
|||||||
Control: fmr-1:8443 (SPKI pin `zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg=`, verified
|
Control: fmr-1:8443 (SPKI pin `zRV9qkiLnRexAeh4RrSfJzbPWO+U/2Oj2/NVM/KfXlg=`, verified
|
||||||
externally over v4+v6). UDP data plane on all four service addresses :8442 — the second IP is
|
externally over v4+v6). UDP data plane on all four service addresses :8442 — the second IP is
|
||||||
the stun-5780 substrate. Daily randomized self-update timer installed (checksum-verified
|
the stun-5780 substrate. Daily randomized self-update timer installed (checksum-verified
|
||||||
against SHA256SUMS; signature verification still TODO before treating the source as untrusted).
|
against SHA256SUMS; signature verification landed 2026-08-02 — see "Release signing" below).
|
||||||
Host config in `/etc/echolot-server.env`. SSH access for sessions: `ssh claude-echolot`.
|
Host config in `/etc/echolot-server.env`. SSH access for sessions: `ssh claude-echolot`.
|
||||||
|
|
||||||
## Server v0.3.0 — STUN + TCP echo + observations + actions (2026-07-31)
|
## Server v0.3.0 — STUN + TCP echo + observations + actions (2026-07-31)
|
||||||
@@ -941,3 +941,549 @@ Also: a `BackHandler` now returns from Settings/History to the run screen. The s
|
|||||||
state variable with nothing connecting it to the back stack, so the system Back gesture left the
|
state variable with nothing connecting it to the back stack, so the system Back gesture left the
|
||||||
app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run
|
app entirely. Enabled only when there is somewhere to go back to, so Back still exits from the run
|
||||||
screen.
|
screen.
|
||||||
|
|
||||||
|
### Upstream throughput (server-v0.6.3, 2026-08-01)
|
||||||
|
The mirror of the downstream case: the client generates the traffic and the server counts it. No
|
||||||
|
grant is involved — the client is sending its own packets, so there is nothing to amplify — but it
|
||||||
|
does need the server's tally, because **only the far end knows how much arrived**. Without that
|
||||||
|
number a sender measures how fast it can *transmit*, which is usually just the speed of the local
|
||||||
|
NIC and is a different question from the one being asked.
|
||||||
|
|
||||||
|
`TYPE_THROUGHPUT_UP` (0x0F) is counted and deliberately **never answered**: a reply would double
|
||||||
|
the traffic and drag the return path into a measurement that is specifically about the outbound
|
||||||
|
one.
|
||||||
|
|
||||||
|
The tally is a counter, not a list, and short-circuits **before** the observation log. A
|
||||||
|
five-second run at 20 Mbps is around ten thousand packets; one struct each would turn a
|
||||||
|
measurement into an allocation storm on a shared server, and nothing needs the per-packet detail
|
||||||
|
since the client holds the send-side record. The gap between the two counts is the loss.
|
||||||
|
|
||||||
|
`direction=up` on the throughput action sends nothing — it zeroes the counter, so a second run in
|
||||||
|
one session measures itself rather than inheriting the first one's packets. The live test asserts
|
||||||
|
`received <= sent`, which is what catches a counter that was never reset.
|
||||||
|
|
||||||
|
Live against fmr: **3125 sent, 3125 counted, 0 % loss, 10.0 Mbit/s** at a 10 Mbit/s request, with
|
||||||
|
`measures_network: false` — correct, since what arrived matched what was offered, so the path was
|
||||||
|
never the constraint.
|
||||||
|
|
||||||
|
### Raw shell dumps leaked the whole LAN (2026-08-01)
|
||||||
|
Found by running the Shizuku shell tier for the first time. The tier works — `tiers.shizuku: true`,
|
||||||
|
`exec_path: UserService` (so the UserService binds on the OnePlus, as recorded), `runs_as
|
||||||
|
shell(2000)`, 7/7 commands — and the run promptly uploaded **every MAC address on the local
|
||||||
|
network** to fmr at the `balanced` level: router, phones, whatever else was on the wifi. Fourteen
|
||||||
|
of them.
|
||||||
|
|
||||||
|
The probes embed raw command output verbatim (`ip neigh`, `ip route`, `id`), which is genuinely
|
||||||
|
good evidence and also a complete household device inventory. The anonymizer could not see it:
|
||||||
|
classification is by field name and by whole-value shape, and `ip_neigh` is one long string that is
|
||||||
|
itself neither a MAC nor an address. measurement-schema.md §9 item 2 had flagged raw dumps as "hard
|
||||||
|
to anonymize" and proposed dropping them from exports; nothing enforced either.
|
||||||
|
|
||||||
|
**Scrubbing beats dropping.** Identifiers inside any unclassified string are now replaced in place,
|
||||||
|
using the same pseudonyms as everywhere else — so a MAC that appears both in a parsed field and in
|
||||||
|
a raw dump still reads as one device. The dump stays readable and auditable: you can still see the
|
||||||
|
neighbour table's shape, the host count, RFC1918 addresses and vendor prefixes. Dropping the
|
||||||
|
evidence would have protected the same data while destroying the reason for collecting it.
|
||||||
|
|
||||||
|
Two implementation notes worth keeping:
|
||||||
|
- **One pass, not three.** Sequential passes re-process their own output: once a MAC became
|
||||||
|
`78:9a:18:xx:yy:zz`, the IPv6 pattern matched it — six hex groups separated by colons *is* an
|
||||||
|
address — and destroyed the vendor prefix the MAC rule had just preserved. Ordered alternation
|
||||||
|
resolves each position once, MAC first.
|
||||||
|
- The patterns are conservative on purpose. A missed address gets caught by another rule or not at
|
||||||
|
all; an over-eager one mangles timestamps and version strings, corrupting evidence to protect
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
`RealDocumentTest` runs the anonymizer over a captured run when `ECHOLOT_REAL_RUN` points at one,
|
||||||
|
and fails on any MAC that survives. It self-skips otherwise, so no one's network is committed to the
|
||||||
|
repo. Against the actual leaked document: **14 MACs in, 0 surviving.**
|
||||||
|
|
||||||
|
Also fixed: the Settings *Preview what an upload would send* button did nothing. It read
|
||||||
|
`UiState.history`, which is empty until the History screen has been opened — the same root cause as
|
||||||
|
the "0 run(s)" count. It now reads the archive directly, and says so when there is nothing to
|
||||||
|
preview rather than silently ignoring the tap.
|
||||||
|
|
||||||
|
### Security: the admin listener was publicly exposed for ~15 minutes (2026-08-01)
|
||||||
|
Moving the admin listener to `[::2]:443` for the UI exposed `/admin/enroll-tokens` and
|
||||||
|
`/admin/selftest` to the internet **with no authentication**. Anyone who could reach
|
||||||
|
`fmr.echo-lot.app` could mint enrolment tokens.
|
||||||
|
|
||||||
|
The listener was designed localhost-only — its own flag help says *"keep localhost"* — and that
|
||||||
|
assumption travelled with it when the address changed. The compounding error: `checkAdminExposure`,
|
||||||
|
added the same day, verifies **encryption** and says nothing about **authentication**. It passed,
|
||||||
|
and a green light on an adjacent property is worse than no check, because it invites you to stop
|
||||||
|
looking.
|
||||||
|
|
||||||
|
Closed by returning to loopback (the TLS and ACME work is retained, just not exposed). All 68 device
|
||||||
|
enrolments matched the timestamps of test runs, so there is no evidence of abuse — but the window
|
||||||
|
existed on a freshly published hostname and absence cannot be proven. 39 unused enrolment tokens
|
||||||
|
were purged, since any could have been minted by someone else and they cost nothing to replace, and
|
||||||
|
63 test devices removed.
|
||||||
|
|
||||||
|
**The admin listener does not become reachable again until it authenticates.** That reorders the UI
|
||||||
|
work: auth on the listener first, everything else after.
|
||||||
|
|
||||||
|
### Open: encrypted uploads, where the operator cannot read the data
|
||||||
|
Not built. Recorded because the shape is decided by a few early choices, and the current design
|
||||||
|
happens to leave the door open.
|
||||||
|
|
||||||
|
The goal: hand someone an account, let them upload, and be unable to read what they uploaded.
|
||||||
|
|
||||||
|
Sketch: a random per-account **master key**, generated on the first device and wrapped under a
|
||||||
|
key derived from a passphrase (PBKDF2-HMAC-SHA256 — stdlib on both sides). The wrapped key is
|
||||||
|
stored server-side as an opaque blob, so a new device signs in, fetches it, and unwraps locally;
|
||||||
|
the server never sees either key. Runs are encrypted client-side with AES-256-GCM, fresh nonce per
|
||||||
|
run. All of this is stdlib in Go and `javax.crypto` in Kotlin — no dependency either side.
|
||||||
|
|
||||||
|
Four consequences that decide whether it is worth it:
|
||||||
|
|
||||||
|
1. **What stays readable determines what the UI can do.** The server builds its index by *parsing*
|
||||||
|
the document — verdict, finding count, started_at. An opaque payload means the client supplies
|
||||||
|
that metadata or the index disappears, and with it retention-by-verdict and any "runs with
|
||||||
|
findings" view. The honest version supplies only run id, timestamp and size, and moves the rest
|
||||||
|
client-side.
|
||||||
|
2. **Lose the passphrase, lose the data.** That is the feature working, and also the support
|
||||||
|
burden. It needs a recovery code printed at setup, not a reset flow — there is nothing to reset.
|
||||||
|
3. **Metadata is not hidden.** The operator still sees which account uploaded, when, how often and
|
||||||
|
how large. "Cannot see it" is about content, not existence, and saying otherwise would oversell.
|
||||||
|
4. **It makes `min_anonymization` unenforceable** — a server cannot check a level it cannot read.
|
||||||
|
That is not a conflict so much as a redundancy: the anonymization floor exists to protect the
|
||||||
|
user from the operator, and encryption does that better. The two should not both be demanded of
|
||||||
|
one upload.
|
||||||
|
|
||||||
|
What keeps this possible: uploads are already stored byte-for-byte as received, and every index
|
||||||
|
field is derived in one function (`runs.Put`). The thing to avoid is admin features that *require*
|
||||||
|
reading content — those would have to be unbuilt later.
|
||||||
|
|
||||||
|
### App sign-in, and an undisclosed dependency it surfaced (2026-08-01)
|
||||||
|
The app can now sign in to the server's identity provider: authorization code with PKCE, a
|
||||||
|
`Sign in` card in settings, and the `echolot://auth` redirect handled alongside the enrolment one
|
||||||
|
(told apart by host, since one spends a token and the other completes an authorization).
|
||||||
|
|
||||||
|
The detail that decides whether this works on a real phone: **the PKCE verifier is written to
|
||||||
|
storage before the browser opens**, not held in memory. Handing control to a browser backgrounds
|
||||||
|
the process and Android may kill it; the callback then arrives at a fresh process. An in-memory
|
||||||
|
verifier works on a developer's device and fails under memory pressure, which is the worst way for
|
||||||
|
a sign-in to break.
|
||||||
|
|
||||||
|
Nothing from the IdP is retained. The ID token proves who is signing in, once, and the device
|
||||||
|
credential authenticates everything after — no access tokens stored, no refresh tokens rotated.
|
||||||
|
|
||||||
|
**A server remains entirely optional.** All eight probes are device-tier; `serverConfigured` gates
|
||||||
|
only upload and the account. But answering that question exposed something worth fixing: two probes
|
||||||
|
hardcode the reference deployment —
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
DnsCanaryProbe(canaryZone = "c.echo-lot.app", ...) // "Hardcoded to the reference deployment"
|
||||||
|
StunProbe(serverHost = "fmr-1.echo-lot.app")
|
||||||
|
```
|
||||||
|
|
||||||
|
so a user with no server of their own still sends DNS and STUN traffic to fmr without being told.
|
||||||
|
For a tool that goes to this much trouble over what leaves the device, an undisclosed dependency on
|
||||||
|
a third party's infrastructure is the wrong default. It should prefer the configured server, and be
|
||||||
|
explicit when there is none. **Closed** — both probes take the enrolled server from settings
|
||||||
|
(canary zone learned from the profile, cleared on re-enroll) and report themselves SKIPPED with
|
||||||
|
the reason when none is configured.
|
||||||
|
|
||||||
|
### v6.broken was a false positive waiting to happen (2026-08-01)
|
||||||
|
A phone could not open `https://fmr.echo-lot.app` while loading the same server by IP literal
|
||||||
|
perfectly well. Two things came out of chasing it.
|
||||||
|
|
||||||
|
**The admin UI is IPv6-only, by consequence rather than intent.** `fmr.echo-lot.app` has an AAAA
|
||||||
|
and no A record — verified identical at Cloudflare, Google and Quad9, so DNS itself is healthy.
|
||||||
|
That follows from reserving all four measurement addresses for testing, which left only `::2` for
|
||||||
|
management, and `::2` has no IPv4 counterpart. Any client without working IPv6 sees an unreachable
|
||||||
|
admin interface — a poor property for the interface you reach *from the networks you are debugging*.
|
||||||
|
|
||||||
|
**And the app's own `v6.broken` finding was unsound.** It fired on exactly one signal — ICMPv6 echo
|
||||||
|
getting no reply — with `Confidence.HIGH`. ICMPv6 echo is widely filtered on networks where IPv6
|
||||||
|
works fine, which is precisely what that phone demonstrated: no ICMPv6 replies, working IPv6 TCP.
|
||||||
|
The finding asserted a cause it had no evidence for, which is the same class of error as the
|
||||||
|
multi-homed `100 % downstream loss` earlier: a confident measurement of something that was not
|
||||||
|
happening.
|
||||||
|
|
||||||
|
Now `v6.no_icmp_reply`, severity low, confidence medium, and the text names *both* explanations
|
||||||
|
instead of choosing one. It is still worth reporting, because filtered ICMPv6 breaks Path MTU
|
||||||
|
Discovery — large packets vanish rather than being reported as too big — which is a real fault even
|
||||||
|
when IPv6 works.
|
||||||
|
|
||||||
|
The proper fix is corroboration: attempt a real IPv6 connection and only call it broken when that
|
||||||
|
fails too. That needs a target, which runs into the hardcoded-reference-deployment issue already
|
||||||
|
open above. **Both closed 2026-08-02** — see "Corroborated IPv6 findings" below.
|
||||||
|
|
||||||
|
## Per-network probing is blocked while a VPN is up (2026-08-01)
|
||||||
|
|
||||||
|
`Network.bindSocket()` fails with `EPERM` for every underlying network when a VPN holds the
|
||||||
|
default route — verified on the OnePlus 15 with Netbird active: `Binding socket to network 101
|
||||||
|
failed: EPERM` for both cellular and wifi. This is Android preventing VPN leaks, not a bug to work
|
||||||
|
around, and it means the whole per-network measurement approach is unavailable to any user with a
|
||||||
|
VPN connected. Worth deciding deliberately rather than discovering per report:
|
||||||
|
|
||||||
|
- The run currently succeeds and simply measures nothing per network. Honest, but silent — the
|
||||||
|
document records `attempted: false` and the UI says green.
|
||||||
|
- A user with a corporate VPN permanently on would get a green run that measured almost nothing.
|
||||||
|
|
||||||
|
Options are to detect the VPN and say so plainly ("this network cannot be measured while a VPN is
|
||||||
|
active"), to measure the tunnel itself as the network under test, or both. **Decided and built
|
||||||
|
2026-08-02**: say so plainly, everywhere the run is read — see "Constrained runs" below.
|
||||||
|
|
||||||
|
Related: `icmp.ping6` now records `attempted` alongside `ok` per network, because collapsing them
|
||||||
|
made the app report "IPv6 is configured, but ICMPv6 gets no reply" about an interface it had never
|
||||||
|
succeeded in sending on — a claim about the user's carrier with no evidence behind it.
|
||||||
|
|
||||||
|
## Reserved measurement addresses, and the web UI on both families (2026-08-01)
|
||||||
|
|
||||||
|
fmr has two IPv4 (.150/.151) and three IPv6 (::150/::151/::2) addresses. `.150`/`::150` now carry
|
||||||
|
the services; `.151`/`::151` are reserved for measurement, declared in `ECHOLOT_RESERVED_ADDRS`.
|
||||||
|
|
||||||
|
Reserved does **not** mean silent. The UDP data plane, the canary DNS and STUN's RFC 5780 alternate
|
||||||
|
all belong there — reserving an address and then forbidding the measurements that need it would
|
||||||
|
defeat the purpose. What must never appear is a service, and above all not ports 80 or 443: a
|
||||||
|
handshake completing on a port known not to be listening is what proves interception, and that
|
||||||
|
proof survives exactly as long as nothing binds those ports. `config.CheckReserved` enforces it at
|
||||||
|
startup, refusing wildcard binds outright (every listener defaults to `:port`, so the next one added
|
||||||
|
will claim reserved addresses without anyone deciding to).
|
||||||
|
|
||||||
|
The first version of the guard was too strict and the live config caught it: it would have refused
|
||||||
|
the existing UDP and DNS binds on `.151`. The rule is about services and web ports, not about
|
||||||
|
listening at all.
|
||||||
|
|
||||||
|
**The adb-beacon receiver was wildcard-bound to `0.0.0.0:443`**, occupying port 443 on every IPv4
|
||||||
|
address including the reserved one — so the IPv4 interception test had been compromised for as long
|
||||||
|
as it had been running, silently. It is now `systemctl disable --now echolot-adb-beacon`; restore
|
||||||
|
with `systemctl enable --now`. Note what this implies: the guard covers this server's own listeners,
|
||||||
|
and a stray process outside its config can still pollute a reserved address. A startup probe that
|
||||||
|
*verifies* 80/443 are actually free on the reserved addresses would be a stronger guarantee than
|
||||||
|
checking our own configuration — built 2026-08-02 (`selftest.ReservedWebPortsFree`, fatal at
|
||||||
|
startup when anything is listening there).
|
||||||
|
|
||||||
|
The admin UI and the ACME responder now take comma-separated addresses like every other listener;
|
||||||
|
they were single-address, which is why the UI could only ever live on `::2`. It serves on `.150:443` and
|
||||||
|
`[::150]:443`; sshd on `.150:2322` and `[::150]:2322`.
|
||||||
|
|
||||||
|
`::2` is gone entirely — unbound, then removed from `/etc/systemd/network/ext.network`. The
|
||||||
|
transition kept it bound throughout and dropped it only after the CNAME landed, because removing it
|
||||||
|
first would have broken both the UI and ACME renewal for the very name the certificate is issued
|
||||||
|
to. Listeners came off before the address did, in that order, or the services would have failed to
|
||||||
|
bind on restart.
|
||||||
|
|
||||||
|
Verified after a full reboot: `fmr.echo-lot.app` answers 200 over both families, `.151`/`::151` are
|
||||||
|
closed on 80 and 443, canary DNS is still up on `.151`, and neither `::2` nor the beacon returns.
|
||||||
|
(`echolot-server` is `After=network-online.target` with `Restart=on-failure`, which is what makes
|
||||||
|
binding specific addresses safe across a boot — a wildcard bind would not have needed it, and that
|
||||||
|
is the trade for the reserved addresses being meaningful.)
|
||||||
|
|
||||||
|
The point of all this: `fmr.echo-lot.app` gained an A record, so the server stopped being reachable
|
||||||
|
only over IPv6 — which is what made it unreachable from a phone with no working IPv6, presenting as
|
||||||
|
"this host does not exist" in two different browsers.
|
||||||
|
|
||||||
|
### If the beacon comes back, it belongs in the web UI
|
||||||
|
|
||||||
|
Not as a separate listener. The receiver being its own Python service on `0.0.0.0:443` is exactly
|
||||||
|
what silently compromised the reserved address, and a second process racing for a port is a
|
||||||
|
recurring problem rather than a one-off: whoever loses the race simply fails to start, and on a
|
||||||
|
reboot which one that is comes down to unit ordering.
|
||||||
|
|
||||||
|
Folding it in costs little and settles several things at once. It would be two routes on the admin
|
||||||
|
UI (`POST` the observed adb port, `GET /apk` for the staged build), behind the TLS the UI already
|
||||||
|
terminates and the certificate it already renews, with no extra port and no wildcard. It also gets
|
||||||
|
authentication for free — the current receiver accepts a port report from anyone who can reach it,
|
||||||
|
which is tolerable for a dev tool on a trusted network and not something to keep once it lives
|
||||||
|
beside an admin session.
|
||||||
|
|
||||||
|
The one thing that changes on the device side is that the POST becomes HTTPS. That is a real
|
||||||
|
certificate rather than a self-signed one, so it costs a URL scheme rather than any trust plumbing.
|
||||||
|
|
||||||
|
## The control plane shares port 443 (2026-08-01)
|
||||||
|
|
||||||
|
`fmr-1.echo-lot.app:443` is the control plane, `fmr.echo-lot.app:443` the admin UI, both on
|
||||||
|
`.150`/`::150`, one listener, selected by SNI for the certificate and by `Host` for the handler.
|
||||||
|
|
||||||
|
The reason is not tidiness, it is reachability. Captive portals, hotel wifi and corporate firewalls
|
||||||
|
routinely permit only 80 and 443 — which is exactly the population of networks this tool exists to
|
||||||
|
diagnose. A control plane on 8443 is unreachable precisely when it matters most, and it fails as
|
||||||
|
"cannot reach server", which tells the user nothing.
|
||||||
|
|
||||||
|
They cannot share a certificate, which is why this needs two names. The control plane is trusted by
|
||||||
|
SPKI pin and so uses a long-lived self-signed certificate; a browser needs one a CA vouches for.
|
||||||
|
One name on one port is one certificate, so the port can only be shared by splitting the names.
|
||||||
|
Pinning the Let's Encrypt key instead was considered and rejected: it survives renewal only while
|
||||||
|
key reuse holds, so a routine key rotation would brick the whole fleet.
|
||||||
|
|
||||||
|
Verified per SNI on 443: `fmr.echo-lot.app` serves `issuer=Let's Encrypt`, `fmr-1.echo-lot.app`
|
||||||
|
serves the self-signed cert whose pin is unchanged (`zRV9…Xlg=`), `/v1/profile` answers 401 on the
|
||||||
|
control name and 303 to the login page on the UI name.
|
||||||
|
|
||||||
|
**8443 stays open.** Devices enrolled before this carry that URL in their settings, and closing it
|
||||||
|
for the sake of a port number would strand every one of them. It can go once no enrolled device
|
||||||
|
still points at it — not before.
|
||||||
|
|
||||||
|
The rule from the naming change still binds: `fmr` may be a CNAME to exactly one host and never a
|
||||||
|
multi-address record, because a pinned client that reaches a different key does not fail over.
|
||||||
|
|
||||||
|
## Constrained runs: a VPN'd run now says so, everywhere (2026-08-02, app 0.2.1)
|
||||||
|
|
||||||
|
The measurement schema gained a top-level `constraints` block (§3) and the app now fills it.
|
||||||
|
`ConstraintDetector` (core-probe) runs before any probe: one throwaway `Network.bindSocket()` per
|
||||||
|
non-VPN network, plus a transport check for an active VPN. The result lands in three places, and
|
||||||
|
all three are deliberate:
|
||||||
|
|
||||||
|
- **`run.constraints`** — for machines. A server aggregating thousands of runs can now separate
|
||||||
|
"measured a healthy network" from "measured almost nothing through a tunnel"; the shapes were
|
||||||
|
identical before.
|
||||||
|
- **A `measurement.vpn_constrained` finding** — for the person reading this run, naming the
|
||||||
|
interfaces that went unmeasured. A constrained run with a quiet findings list still reads as
|
||||||
|
"nothing wrong here".
|
||||||
|
- **The §7.3 verdict** — `Verdicts.derive` takes the constraints and returns INCONCLUSIVE
|
||||||
|
outright for a per-network-blocked run, whatever the category lights say; the run screen shows
|
||||||
|
an amber "Measured through a VPN" banner above the verdict so INCONCLUSIVE reads as the OS
|
||||||
|
refusing, not the app failing.
|
||||||
|
|
||||||
|
Detection is one bind per network rather than parsing per-test `attempted:false` breadcrumbs, so
|
||||||
|
it cannot drift when probe evidence formats change.
|
||||||
|
|
||||||
|
## Corroborated IPv6 findings: v6.broken is back, with evidence (2026-08-02, app 0.2.1)
|
||||||
|
|
||||||
|
The new `V6ConnectProbe` (test type `v6.brokenness`) attempts a real TCP connection over IPv6 to
|
||||||
|
the configured server's :443, per network that *claims* IPv6 (global address or v6 default
|
||||||
|
route) — IPv4-only networks are not attempted, since their failure is by design and would
|
||||||
|
manufacture the exact false positive this exists to kill. The finding derivation is now three-way:
|
||||||
|
|
||||||
|
- ICMPv6 silent, TCP works → `v6.no_icmp_reply` at **high** confidence, retitled "ICMPv6 is
|
||||||
|
filtered here — IPv6 itself works" (still reported: filtered ICMPv6 breaks PMTUD).
|
||||||
|
- ICMPv6 silent, TCP fails too → **`v6.broken`** (high severity, reinstated in the registry +
|
||||||
|
findings-registry.md): two independent transports silent on a network advertising IPv6.
|
||||||
|
- No corroboration (no server configured, or the connect never got as far as sending) → the
|
||||||
|
two-explanation `v6.no_icmp_reply` at medium confidence, unchanged.
|
||||||
|
|
||||||
|
Like STUN and the canary, the probe SKIPs honestly when no server is configured — corroboration
|
||||||
|
is a benefit of enrollment, not a reason to borrow fmr.
|
||||||
|
|
||||||
|
## Server: reserved 80/443 verified against the OS, and signed releases (2026-08-02)
|
||||||
|
|
||||||
|
**Reserved-address startup probe.** `serve()` now proves 80/443 are actually free on every
|
||||||
|
`ECHOLOT_RESERVED_ADDRS` address before starting: a throwaway bind per port
|
||||||
|
(`selftest.ReservedWebPortsFree`), fatal on EADDRINUSE with the offending address named — the
|
||||||
|
check `CheckReserved` cannot do, because a stray process outside our config (the adb-beacon
|
||||||
|
receiver on `0.0.0.0:443` was exactly that) is invisible to configuration checks. Bind errors
|
||||||
|
that are not "in use" (typo'd address, address not on this host) warn instead of refusing —
|
||||||
|
they are config problems, not pollution.
|
||||||
|
|
||||||
|
**Release signing.** Self-update now trusts a signature, not a host. CI signs `SHA256SUMS` with
|
||||||
|
an ed25519 key (`relsign` package, `cmd/release-sign`) and the updater refuses any release whose
|
||||||
|
`SHA256SUMS.sig` is missing or does not verify against the public key baked into the binary
|
||||||
|
(`selfupdate.DefaultPublicKeyB64`; operators with their own pipeline override via
|
||||||
|
`ECHOLOT_SELF_UPDATE_PUBKEY`). The private key exists in exactly two places: the Gitea Actions
|
||||||
|
secret `RELEASE_SIGNING_KEY`, and the offline original on the dev PC at
|
||||||
|
`~/.echolot/release-signing-key`. It is deliberately NOT on fmr and NOT in the repo — a
|
||||||
|
compromised release host can withhold updates but no longer inject one. CI hard-fails when the
|
||||||
|
secret is missing (an unsigned release would strand every verifying server) and cross-checks the
|
||||||
|
signature against the key in the source it just built.
|
||||||
|
|
||||||
|
**ACTION REQUIRED before the next `server-v*` tag:** add the Gitea repo secret
|
||||||
|
`RELEASE_SIGNING_KEY` (Settings → Actions → Secrets) with the contents of
|
||||||
|
`~/.echolot/release-signing-key` from the dev PC. Ordering is safe: the currently deployed
|
||||||
|
v0.3.x updater does not verify, so it will happily install the first signed release; every
|
||||||
|
release after that is verified. **Done 2026-08-02** — the secret is in place. (The "v0.3.x"
|
||||||
|
above should read "the currently deployed release": deployments had moved on to v0.9.x by the
|
||||||
|
time signing landed; the point — the deployed updater predates verification and will accept the
|
||||||
|
first signed release — is unchanged.)
|
||||||
|
|
||||||
|
## Prober fold: traceroute.udp4 and the mDNS inventory go production (2026-08-02, app 0.2.2)
|
||||||
|
|
||||||
|
The two highest-value validated capabilities moved from the prober into `core-probe`:
|
||||||
|
|
||||||
|
- **`traceroute.udp4`** (`TracerouteProbe`): UDP traceroute reading ICMP time-exceeded off the
|
||||||
|
socket error queue via `Os.recvmsg(MSG_ERRQUEUE)` through the reflection facade — no root, no
|
||||||
|
raw socket, no JNI, ~250 ms for six hops. Emits the schema's `TracerouteEvidence` (rtt in ns).
|
||||||
|
`OsAbi` came with it, including the measured fact that `Os.getsockoptInt` exists on neither
|
||||||
|
known device, so PMTU must always be read from the errqueue (`ee_info`), never
|
||||||
|
`getsockopt(IP_MTU)`. The load-bearing line survived the port: EAGAIN out of the reflected
|
||||||
|
`recvmsg` means "queue empty", not failure.
|
||||||
|
- **`local.mdns_inventory`** (`MdnsInventoryProbe`): MulticastLock + NSD discovery, the service
|
||||||
|
inventory that doubles as the VLAN-leakage detector. Both hardware lessons kept: the
|
||||||
|
`_services._dns-sd._udp.` meta-query returns 0 beside live services on both devices (so the
|
||||||
|
concrete types are the measurement and the meta-query result is itself evidence), and the
|
||||||
|
listen window is 10 s because 4 s missed services.
|
||||||
|
|
||||||
|
Still to fold, in order: the Shizuku dump *parsers* (the raw `link.ip_monitor` captures already
|
||||||
|
hold two divergent vendor formats that could feed `link.ra_source` and `sec.arp_watch`);
|
||||||
|
`multinetwork.request_and_bind` (extend ConstraintDetector to *request* transports rather than
|
||||||
|
only probing present ones); `peer.ble_advertise` (needs three new permissions and a peer mode to
|
||||||
|
exist first).
|
||||||
|
|
||||||
|
## Server v0.9.2: trains, real TTL/DSCP/ECN, rate limits (2026-08-02)
|
||||||
|
|
||||||
|
The spec-vs-implementation gap audit closed its top items; protocol_version 1.0.0 → 1.0.1
|
||||||
|
(additive — below 1.0.0 the minor is the breaking axis, and nothing here breaks an old client):
|
||||||
|
|
||||||
|
- **Upstream trains** (§3.2, types 0x03/0x04/0x05): per-train bounded columnar buffer (8192
|
||||||
|
rows, head kept on overflow with `Truncated` set — mirrors the schema's `evidence_truncated`
|
||||||
|
honesty), TRAIN_REPORT split across ≤1200-byte datagrams, grant-free with the §3.4 argument
|
||||||
|
spelled out (a 17-byte report row answers a ≥36-byte HMAC-valid packet). Unknown train id
|
||||||
|
gets a zero-row report: "nothing arrived" is an answer. Also surfaced as `udp.trains` in the
|
||||||
|
observations API.
|
||||||
|
- **Real TTL/DSCP/ECN observation** (§3.3): the read loop is `ReadMsgUDPAddrPort` with
|
||||||
|
IP_RECVTTL/IP_RECVTOS/IPV6_RECVHOPLIMIT/IPV6_RECVTCLASS cmsgs on Linux; `0xFF` stays the
|
||||||
|
"not observed" sentinel elsewhere. This unblocks `sec.dscp_ecn_survival` both directions,
|
||||||
|
paired with the new `dscp` parameter on `downtrain` (validated 0–63, refused not clamped,
|
||||||
|
`dscp_applied` in the response).
|
||||||
|
- **Rate limiting** (§2.5, was entirely absent): token buckets keyed per credential AND per
|
||||||
|
source IP; 429 + Retry-After on session/action creation (`/v1/profile` stays ungated), silent
|
||||||
|
drop on the data plane — charged after the HMAC gate so a spoofed flood cannot drain a
|
||||||
|
victim's budget, before the replay window so a dropped seq stays usable. UDP ceilings default
|
||||||
|
above the largest legitimate run (a 200 Mbps throughput test), because a rate limit that
|
||||||
|
clips a real measurement produces a confidently wrong number.
|
||||||
|
- **`action_id` in every granted packet** (§5/§9): payload bytes [8:16] across all granted
|
||||||
|
types, so overlapping actions are attributable. Verified the deployed Kotlin client parses
|
||||||
|
only ECHO_RESP and MTU_ACK payloads, so the reshuffle strands nobody.
|
||||||
|
- **Canary log retention**: the stated 24 h privacy default is now enforced
|
||||||
|
(`ECHOLOT_DNS_LOG_RETENTION_H`), where before the log was time-unbounded.
|
||||||
|
- **`POST /admin/enroll-tokens`** now answers the spec's JSON shape under content negotiation;
|
||||||
|
the README's curl works as documented.
|
||||||
|
- Spec §2.3 registry gained `downtrain` and `tcp-echo`, which the server had been advertising
|
||||||
|
as strings a conformant client must ignore.
|
||||||
|
|
||||||
|
Client-side counterparts still to build: sending 0x03 trains + parsing 0x05 reports
|
||||||
|
(`train.udp_updown`), and passing `dscp` on downtrain actions.
|
||||||
|
|
||||||
|
## ⚠ Version lineage broken: fmr runs v0.11.2, the repo's tags stop at v0.9.x (2026-08-02)
|
||||||
|
|
||||||
|
Discovered while preparing to self-update fmr to the freshly released server-v0.9.2:
|
||||||
|
**fmr runs v0.11.2** (binary installed 2026-08-02 08:51), but this repo's remote has tags only
|
||||||
|
up to `server-v0.9.1`, master fast-forwarded cleanly from this machine, there is no v0.10/v0.11
|
||||||
|
release in Gitea, no source checkout or Go toolchain on fmr, and no deploy script in this repo
|
||||||
|
that stamps versions. Conclusion: v0.11.2 was cross-built from a clone whose commits were never
|
||||||
|
pushed — presumably another dev machine.
|
||||||
|
|
||||||
|
Consequences until resolved:
|
||||||
|
- **Do NOT run `--self-update` on fmr.** Gitea's `/releases/latest` is the *newest-created*
|
||||||
|
release, which is now `server-v0.9.2` — semantically older than the deployed binary; the
|
||||||
|
updater compares strings, not SemVer, and would happily "update" v0.11.2 down to it. No
|
||||||
|
automatic risk exists (fmr has no update timer installed, only the cert timer), but a manual
|
||||||
|
run would downgrade.
|
||||||
|
- The next real release must be tagged **above v0.11.2** (e.g. `server-v0.11.3` or `v0.12.0`)
|
||||||
|
*after* the missing commits are pushed, so "latest" becomes truly latest again.
|
||||||
|
- The unpushed v0.10–v0.11 work needs to be found and pushed from whichever machine built it,
|
||||||
|
or the deployed binary's provenance re-established some other way, before the release channel
|
||||||
|
can be trusted again.
|
||||||
|
|
||||||
|
**Resolved same day.** The binary itself settled it: `go version -m` on the deployed executable
|
||||||
|
shows `vcs.revision=d5b1bab` — a commit on this repo's master — built 2026-08-02 08:36 UTC with a
|
||||||
|
hand-stamped `-X main.Version=v0.11.2` and a dirty tree (`vcs.modified=true`, the then-uncommitted
|
||||||
|
schema doc). An earlier session stamped release numbers ahead of the tag line; no code was ever
|
||||||
|
missing. Current master is tagged and released as **server-v0.11.3** (signed), restoring a
|
||||||
|
monotonic, tag-backed lineage above the deployed number. The rule going forward: **the version a
|
||||||
|
binary is stamped with must be a pushed `server-v*` tag** — an ad-hoc stamp above the tag line
|
||||||
|
poisons `/releases/latest` for the string-comparing updater the moment anyone tags honestly again.
|
||||||
|
The stale `server-v0.9.2` release (same code lineage, wrong number, created during the confusion)
|
||||||
|
remains in Gitea but is harmless now that v0.11.3 outranks it as latest.
|
||||||
|
|
||||||
|
## LLDP and CDP are root-tier, and that is a hard boundary (2026-08-02)
|
||||||
|
|
||||||
|
Asked for alongside SSDP in long mode; they belong to a different tier and no amount of app-side
|
||||||
|
cleverness moves them. LLDP is an EtherType `0x88CC` frame to `01:80:C2:00:00:0E`; CDP is an
|
||||||
|
LLC/SNAP frame to `01:00:0C:CC:CC:CC`. Neither is IP, so neither is ever delivered to a socket an
|
||||||
|
app can open — receiving them needs `AF_PACKET` with `CAP_NET_RAW`, which is root. Shizuku does
|
||||||
|
not bridge this either: the ADB shell user (uid 2000) has no `CAP_NET_RAW`, and stock devices do
|
||||||
|
not ship `tcpdump`. Android's unprivileged ICMP sockets are what make `icmp.ping4` work without
|
||||||
|
root; there is no equivalent back door for raw L2 receive.
|
||||||
|
|
||||||
|
Worth building in the root module when it lands, because the payoff is large: LLDP names the
|
||||||
|
switch, the port and the VLAN a device is attached to, which is the best available answer to
|
||||||
|
"where in this building am I actually plugged in", and CDP does the same on Cisco gear. Until
|
||||||
|
then they are recorded as absent capabilities rather than left to look unimplemented.
|
||||||
|
|
||||||
|
What IS reachable at app tier, and what long mode now listens for instead: SSDP (passive NOTIFY
|
||||||
|
plus periodic M-SEARCH), LLMNR, NetBIOS-NS and WS-Discovery — all IP multicast/broadcast, all
|
||||||
|
sockets an app may open. The security reading matters as much as the inventory: LLMNR and
|
||||||
|
NetBIOS-NS being live on a segment is a finding in itself, since both are trivially spoofable.
|
||||||
|
|
||||||
|
**One of those four cannot run at app tier either, and says so.** `local.netbios_inventory`
|
||||||
|
reports `unsupported` with the bind error attached: UDP 137 is below 1024, and Android reserves
|
||||||
|
privileged ports exactly like any other Linux. The decoder, the evidence shape and the registry
|
||||||
|
id are built and tested, waiting for the Shizuku tier to supply a socket. Recorded as a result
|
||||||
|
rather than dropped, so nobody later reads its absence as an oversight.
|
||||||
|
|
||||||
|
Two deliberate restraints in that work, both worth keeping: no active WS-Discovery Probe (an
|
||||||
|
M-SEARCH is traffic every SSDP device expects constantly, whereas a WSD Probe from an unknown
|
||||||
|
host announces *this* device to the segment), and no NBSTAT sweep (that is host scanning, not
|
||||||
|
measurement — the app listens to what a network broadcasts, it does not interrogate its
|
||||||
|
neighbours). The parsers are also hardened against the input they will actually meet: a DNS
|
||||||
|
compression pointer is refused rather than followed (the classic parser hang), and the WSD
|
||||||
|
extractor is string-based on purpose, tested against an entity bomb and 20 000-deep nesting.
|
||||||
|
|
||||||
|
## Design note: what BLE between two devices is actually for (2026-08-02, not built)
|
||||||
|
|
||||||
|
Two or more phones running Echolot, talking over Bluetooth LE. The schema already anticipates
|
||||||
|
this — `Trigger.PEER` and the whole `peer.*` test family (`peer.reachability`, `peer.isolation`,
|
||||||
|
`peer.multicast`, `peer.lan_train`, `peer.lease_diff`) are in the registry, unused — and the
|
||||||
|
prober measured `peer.ble_advertise` **SUPPORTED on both known devices**, so the mechanism is
|
||||||
|
proven; what has been missing is a reason that beats "use the server".
|
||||||
|
|
||||||
|
**The reason is that BLE is out-of-band.** Everything else this app does depends on the network
|
||||||
|
under test being at least partly functional. A second device reachable over a radio that shares
|
||||||
|
nothing with the wifi turns several measurements from ambiguous into conclusive:
|
||||||
|
|
||||||
|
1. **Client isolation becomes measurable at all.** Today, "I sent a packet to the peer and heard
|
||||||
|
nothing" cannot distinguish AP client isolation from the peer being asleep, gone, or on a
|
||||||
|
different VLAN — the failure mode is silence, and silence has too many parents. With BLE the
|
||||||
|
peer confirms out-of-band that it was listening on address X at time T, so silence over IP
|
||||||
|
becomes *proof* of isolation rather than a guess. This is the single strongest argument for
|
||||||
|
the feature, and it mirrors the rule this project keeps rediscovering: a measurement that
|
||||||
|
cannot separate "nothing happened" from "nothing was tried" is not a measurement.
|
||||||
|
2. **Differential diagnosis: the network or this phone?** Two devices on the same SSID, one
|
||||||
|
resolving DNS and one not, settles in seconds what a single device cannot settle at all —
|
||||||
|
and it is the same distinction `system_verdict` exists to draw, only with a second opinion
|
||||||
|
instead of Android's. Natural finding: *this device fails where a peer on the same link
|
||||||
|
succeeds* → look at the device (private DNS, ad blocker, per-client router rule, MAC
|
||||||
|
randomization), not the router.
|
||||||
|
3. **Two DHCP servers on one L2**, the classic invisible fault: peers compare lease source,
|
||||||
|
subnet and gateway (`peer.lease_diff`). Disagreement is conclusive and needs no server.
|
||||||
|
4. **Coverage and roaming**, later: several devices sampling RSSI in different rooms, exchanging
|
||||||
|
summaries over BLE, gives a picture no single device standing in one place can produce.
|
||||||
|
|
||||||
|
**What crosses the link is a summary, never the document.** A measurement document describes
|
||||||
|
someone's home network in detail; broadcasting it to whoever is nearby would betray the whole
|
||||||
|
posture of §8. The peer payload should be: a *hashed* network identity (so two devices can agree
|
||||||
|
they are on the same L2 without either putting the SSID/BSSID on the air in the clear), the §7.3
|
||||||
|
category verdicts, the finding codes, and an IP endpoint plus a one-shot nonce for the LAN tests.
|
||||||
|
Findings and verdicts are already the interpretation layer — exactly the right granularity to
|
||||||
|
share.
|
||||||
|
|
||||||
|
**Privacy constraints, which are not optional here.** A BLE advertiser is a tracking beacon: it
|
||||||
|
must be user-initiated, time-boxed to the run, carry no identifier that is stable across runs
|
||||||
|
(the resolvable-private-address default plus a per-session ephemeral id), and pair by a code the
|
||||||
|
two humans can see. "Discoverable by default" would make this app a worse citizen than the
|
||||||
|
networks it audits.
|
||||||
|
|
||||||
|
**Deliberately not doing:** clock synchronisation over BLE. GATT latency is jitter measured in
|
||||||
|
tens of milliseconds, which is the same order as the one-way delays worth measuring; peers should
|
||||||
|
sync against the server's `time.server_offset` and use BLE only to correlate run ids. Nor should
|
||||||
|
BLE become a transport for uploads — it is a *comparison* channel.
|
||||||
|
|
||||||
|
Staging when it happens: `peer.isolation` first (highest value, needs only advertise + connect +
|
||||||
|
a nonce exchange), then `peer.lease_diff` (pure summary comparison, no extra plumbing), then the
|
||||||
|
rest. Needs `BLUETOOTH_ADVERTISE/CONNECT/SCAN` in the manifest, which the app does not yet
|
||||||
|
request.
|
||||||
|
|
||||||
|
## v0.11.3 live on fmr; trains validated end to end (2026-08-02)
|
||||||
|
|
||||||
|
Deployed via `--self-update` (the pre-signing v0.11.2 updater accepted the first signed release,
|
||||||
|
as planned; every later update verifies). Startup clean on the real host — the reserved-port
|
||||||
|
check passed against the OS, self-test green, capabilities unchanged plus the new machinery.
|
||||||
|
|
||||||
|
**`train.udp_updown` validated against production**: `LiveUpstreamTrainTest` from this PC sent
|
||||||
|
120 packets; the server's ledger counted 120, both columnar report parts arrived, loss 0.0 %,
|
||||||
|
`truncated=false`. The 0x03/0x04/0x05 path works, client and server, over the real internet.
|
||||||
|
|
||||||
|
Two operational bugs surfaced doing it:
|
||||||
|
- **CLI-minted tokens are lost while the daemon runs.** `devices.json` is loaded once at startup
|
||||||
|
and held in memory; `--mint-enroll-token` writes to disk, the running daemon never re-reads,
|
||||||
|
answers "unknown token", and clobbers the token on its next write. `enroll-link.sh` has only
|
||||||
|
ever worked by timing luck. Workaround used: mint, `systemctl restart echolot-server`, then
|
||||||
|
redeem. Real fix belongs server-side (re-read on miss, or route the CLI mint through the
|
||||||
|
running daemon).
|
||||||
|
- **`test-fmr.sh` still mints against `127.0.0.1:8444`**, which no longer exists (the admin API
|
||||||
|
moved to authenticated :443). Needs the same CLI-mint flow enroll-link.sh uses — plus the
|
||||||
|
restart caveat above until that bug is fixed.
|
||||||
|
|||||||
@@ -50,6 +50,13 @@ because it looks authoritative.
|
|||||||
| `connectivity.downstream_reorder` | low | Downstream packets arrive in a different order than they were sent. | — |
|
| `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.captive_portal` | medium | A captive portal is intercepting connectivity checks. | — |
|
||||||
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
| `connectivity.no_internet` | high | Android's own connectivity checks fail on this network. | — |
|
||||||
|
| `connectivity.link_flapping` | medium | A network dropped and came back one or more times during the run. | A momentary probe failure: the drop was watched happening, not inferred from silence. |
|
||||||
|
|
||||||
|
`connectivity.link_flapping` is only reachable from a **long run** (`run.mode: "long"`,
|
||||||
|
measurement-schema §3). It is derived from `networks[].changes[]` rather than from any test's
|
||||||
|
evidence, because no one-shot probe can produce it: the probes before and after a four-second drop
|
||||||
|
both succeed. The emitter escalates to *high* from three completed drop-and-return cycles, and
|
||||||
|
requires the cycle to complete — a network switched off partway through a run is not flapping.
|
||||||
|
|
||||||
### mtu
|
### mtu
|
||||||
|
|
||||||
@@ -89,9 +96,29 @@ rolled up under *connectivity* instead — the third occurrence of rule 1 being
|
|||||||
|
|
||||||
| code | severity | means | rules out |
|
| code | severity | means | rules out |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `v6.broken` | medium | IPv6 is configured on this network but does not work. | Absence of IPv6: it is provisioned, it simply fails. |
|
| `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.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
|
`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
|
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.
|
people to ignore the light — the one thing a diagnostic must never do.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ Export encoding: UTF-8 JSON, gzip for files (`.echolot.json.gz`), share intent u
|
|||||||
{
|
{
|
||||||
"id": "0198c5f2-...-uuidv7",
|
"id": "0198c5f2-...-uuidv7",
|
||||||
"trigger": "manual | scheduled | monitor | peer",
|
"trigger": "manual | scheduled | monitor | peer",
|
||||||
|
"mode": "short | long",
|
||||||
"started_at": "2026-07-29T14:03:21.114Z",
|
"started_at": "2026-07-29T14:03:21.114Z",
|
||||||
"ended_at": "2026-07-29T14:07:44.902Z",
|
"ended_at": "2026-07-29T14:07:44.902Z",
|
||||||
"clock": {
|
"clock": {
|
||||||
@@ -52,12 +53,47 @@ 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*.
|
||||||
|
|
||||||
|
`mode` records how long the run watched, and it exists because **it changes what a reader may
|
||||||
|
conclude from absence**. A `short` run is a sequence of one-shot probes — each looks at the network
|
||||||
|
for a second or two and moves on — which characterises the network's *configuration* well and is
|
||||||
|
structurally blind to anything intermittent. A `long` run starts continuous listeners at t=0, runs
|
||||||
|
the same battery beside them, and keeps sampling until its window closes; the window's length is
|
||||||
|
recorded in the `params` of the tests the listeners produce, not here.
|
||||||
|
|
||||||
|
The consequence is asymmetric and matters more than the field looks. A finding is worth the same in
|
||||||
|
either mode: a drop that was observed, was observed. Silence is not. "No link changes were seen" is
|
||||||
|
evidence of a stable link after five minutes of watching and is evidence of nothing at all after a
|
||||||
|
thirty-second run, in which a link could drop and return between two consecutive probes without
|
||||||
|
leaving a mark anywhere in the document. Consumers — a diff between two runs, a dashboard counting
|
||||||
|
how often a fault occurs, a person reading one report — must therefore not treat the absence of a
|
||||||
|
time-dependent finding in a `short` run as its refutation, and must not compare the two modes as if
|
||||||
|
they had asked the same question. `connectivity.link_flapping` is the first finding that only a
|
||||||
|
`long` run can reach; `networks[].changes[]` (§4) is likewise populated only by a long run's
|
||||||
|
listener, and an empty `changes[]` in a short run means "not watched", never "nothing happened".
|
||||||
|
|
||||||
|
Absent `mode` means `short`: it was added after the first documents were written, and every one of
|
||||||
|
them was a battery of one-shot probes.
|
||||||
|
|
||||||
|
`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.
|
||||||
@@ -97,12 +133,24 @@ A run may exercise several networks simultaneously (Wi-Fi + cellular + USB ether
|
|||||||
"changes": [
|
"changes": [
|
||||||
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
|
{ "at_mono_ns": 91000000000, "kind": "lost | gained | link_changed",
|
||||||
"detail": { /* new link snapshot or diff */ } }
|
"detail": { /* new link snapshot or diff */ } }
|
||||||
]
|
],
|
||||||
|
"app_usable": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present".
|
`routes[].proto` and lifetime fields are Shizuku-tier data (`ip route`/`ip addr`); app-tier snapshots leave them absent — absence means "not observed", never "not present".
|
||||||
|
|
||||||
|
`app_usable` records whether an ordinary app may send on this network at all. Android lists the
|
||||||
|
carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — alongside the real ones, and they
|
||||||
|
carry neither `INTERNET` nor `NOT_RESTRICTED`; binding to one needs
|
||||||
|
`CONNECTIVITY_USE_RESTRICTED_NETWORKS`, which is signature-level and unobtainable for a normal
|
||||||
|
app. Those networks are therefore permanently unmeasurable, and that is a property of Android's
|
||||||
|
permission model rather than of the link. They stay in `networks[]` because they are genuinely
|
||||||
|
present — an interface silently missing from the inventory is its own kind of lie — but a
|
||||||
|
consumer must not read the absence of tests against them as a fault, and they are **not**
|
||||||
|
`constraints.unmeasured_networks` (§3): nothing was prevented, the run was never entitled to
|
||||||
|
measure them.
|
||||||
|
|
||||||
## 5. `server_sessions[]`
|
## 5. `server_sessions[]`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
+31
-1
@@ -84,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
|
||||||
|
|
||||||
@@ -111,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)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ plugins {
|
|||||||
//
|
//
|
||||||
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
|
// 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.
|
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
|
||||||
val appVersionName = "0.2.0"
|
val appVersionName = "0.3.0"
|
||||||
|
|
||||||
fun versionCodeOf(semver: String): Int {
|
fun versionCodeOf(semver: String): Int {
|
||||||
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
|
||||||
@@ -34,8 +34,16 @@ android {
|
|||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
// 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).
|
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||||
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
// Empty: the collection endpoint this pointed at was the adb-beacon receiver, which held
|
||||||
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
// 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
|
// 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.
|
// parser anyway — sent to servers so they can apply their compatibility window.
|
||||||
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
|
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_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.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!--
|
||||||
|
The adb relay (AdbRelayService) only. It runs in the foreground because it must keep
|
||||||
|
watching while the tablet sits unattended with its screen off, and dataSync is the type
|
||||||
|
that describes it: it carries an observation off the LAN, nothing more. Android 15 caps
|
||||||
|
dataSync at a few hours a day, which is acceptable for a tool that is switched on for a
|
||||||
|
debugging session rather than left running forever.
|
||||||
|
-->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -22,7 +33,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -39,8 +51,28 @@
|
|||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data android:scheme="echolot" android:host="enroll" />
|
<data android:scheme="echolot" android:host="enroll" />
|
||||||
</intent-filter>
|
</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>
|
</activity>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Not exported: nothing outside this app has any business starting a relay that reports
|
||||||
|
where this device can be reached.
|
||||||
|
-->
|
||||||
|
<service
|
||||||
|
android:name=".AdbRelayService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -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,136 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import java.net.Inet4Address
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches adbd's own mDNS advertisement on this device and reports the endpoint to the server.
|
||||||
|
*
|
||||||
|
* This replaces the retired Python adb-beacon, and exists for one reason: **mDNS does not cross
|
||||||
|
* subnets**. A developer on another network cannot see `_adb-tls-connect._tcp` at all, while the
|
||||||
|
* wireless-debug port rotates every few minutes — so the port has to be carried out of the LAN by
|
||||||
|
* something sitting inside it. That is this. A tablet parked on the test network relays; the
|
||||||
|
* developer reads the endpoint back from the server.
|
||||||
|
*
|
||||||
|
* Two hard-won rules from the beacon, both load-bearing:
|
||||||
|
*
|
||||||
|
* - **Resolve each service instance exactly ONCE.** Resolving adbd's advertisement makes adbd
|
||||||
|
* re-arm its connection and post a "wireless debugging connected" notification; re-resolving on
|
||||||
|
* every heartbeat turns that into a stream of them. The guard is cleared only when the service
|
||||||
|
* is *lost*, which is also what catches rotation: the new advertisement is a new instance, gets
|
||||||
|
* resolved once, and is reported within seconds.
|
||||||
|
* - **Do not run this on the OnePlus.** On network churn that device drops and re-publishes its
|
||||||
|
* advertisement repeatedly, so lost/found cycles keep clearing the guard and each resolve
|
||||||
|
* re-arms adbd. Guarding reduces but cannot eliminate the noise; the Lenovo tablet is the
|
||||||
|
* intended host, which is also why relaying is a mode rather than something always on.
|
||||||
|
*/
|
||||||
|
class AdbRelay(
|
||||||
|
private val ctx: Context,
|
||||||
|
private val onEvent: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
private val nsd = ctx.getSystemService(NsdManager::class.java)
|
||||||
|
|
||||||
|
/** Instances already resolved, by service name — the re-arm guard described above. */
|
||||||
|
private val resolved = HashSet<String>()
|
||||||
|
|
||||||
|
/** Last endpoint reported, so the heartbeat re-posts from cache instead of re-resolving. */
|
||||||
|
@Volatile var lastEndpoint: Endpoint? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
data class Endpoint(val host: String, val port: Int, val serviceName: String)
|
||||||
|
|
||||||
|
private var listener: NsdManager.DiscoveryListener? = null
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (nsd == null) {
|
||||||
|
onEvent("mDNS unavailable on this device")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (listener != null) return
|
||||||
|
val l = object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onStartDiscoveryFailed(type: String?, code: Int) {
|
||||||
|
onEvent("discovery failed to start (code $code)")
|
||||||
|
}
|
||||||
|
override fun onStopDiscoveryFailed(type: String?, code: Int) {}
|
||||||
|
override fun onDiscoveryStarted(type: String?) {
|
||||||
|
onEvent("watching for adbd on this network")
|
||||||
|
}
|
||||||
|
override fun onDiscoveryStopped(type: String?) {}
|
||||||
|
|
||||||
|
override fun onServiceFound(info: NsdServiceInfo?) {
|
||||||
|
val name = info?.serviceName ?: return
|
||||||
|
// The guard: one resolve per instance, ever. adbd re-arms on every resolve.
|
||||||
|
if (!resolved.add(name)) return
|
||||||
|
resolve(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceLost(info: NsdServiceInfo?) {
|
||||||
|
// Rotation: the old instance is gone, so allow the replacement to be resolved.
|
||||||
|
info?.serviceName?.let { resolved.remove(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listener = l
|
||||||
|
runCatching { nsd.discoverServices(ADB_SERVICE, NsdManager.PROTOCOL_DNS_SD, l) }
|
||||||
|
.onFailure { onEvent("could not start discovery: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
listener?.let { l -> runCatching { nsd?.stopServiceDiscovery(l) } }
|
||||||
|
listener = null
|
||||||
|
resolved.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION") // the callback-based resolve is the one that exists across our minSdk
|
||||||
|
private fun resolve(info: NsdServiceInfo) {
|
||||||
|
val cb = object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(i: NsdServiceInfo?, code: Int) {
|
||||||
|
// Let a failed instance be retried: the guard exists to stop *successful*
|
||||||
|
// re-resolution, not to give up on a transient failure.
|
||||||
|
i?.serviceName?.let { resolved.remove(it) }
|
||||||
|
onEvent("resolve failed (code $code)")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceResolved(i: NsdServiceInfo?) {
|
||||||
|
val host = i?.host?.hostAddress ?: return
|
||||||
|
// adbd advertises on every address it listens on, including link-local v6. The
|
||||||
|
// reachable one from a developer's subnet is the routable v4 address, and it is
|
||||||
|
// also the only one worth relaying — a link-local address means nothing off-link.
|
||||||
|
if (i.host !is Inet4Address) return
|
||||||
|
// Only this device's own advertisement: on a shared network several phones may
|
||||||
|
// have wireless debugging on, and relaying a neighbour's port would send a
|
||||||
|
// developer to the wrong device.
|
||||||
|
if (host != localIp()) return
|
||||||
|
lastEndpoint = Endpoint(host, i.port, i.serviceName ?: "adb")
|
||||||
|
onEvent("found adbd at $host:${i.port}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCatching { nsd?.resolveService(info, cb) }
|
||||||
|
.onFailure {
|
||||||
|
resolved.remove(info.serviceName)
|
||||||
|
onEvent("resolve threw: ${it.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This device's own IPv4 address on the wifi it is relaying from. */
|
||||||
|
private fun localIp(): String? = runCatching {
|
||||||
|
val wifi = ctx.getSystemService(WifiManager::class.java) ?: return null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val ip = wifi.connectionInfo.ipAddress
|
||||||
|
if (ip == 0) return null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
String.format(
|
||||||
|
"%d.%d.%d.%d",
|
||||||
|
ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val ADB_SERVICE = "_adb-tls-connect._tcp"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
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.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps [AdbRelay] running and posts what it finds to the enrolled server.
|
||||||
|
*
|
||||||
|
* A foreground service because the whole point is to be useful while nobody is looking at the
|
||||||
|
* tablet: a background process is frozen within minutes of the screen going off, and a relay that
|
||||||
|
* stops relaying the moment it is left alone would be worse than none — it would be trusted right
|
||||||
|
* up until the moment it went quiet.
|
||||||
|
*
|
||||||
|
* The heartbeat re-posts the CACHED endpoint and never re-resolves. Resolving adbd's advertisement
|
||||||
|
* makes adbd re-arm its connection and raise a "wireless debugging connected" notification, so a
|
||||||
|
* heartbeat that re-resolved would turn a background convenience into a stream of notifications on
|
||||||
|
* a device sitting on a shelf. Rotation is still caught, because losing the old advertisement
|
||||||
|
* clears the resolve guard in [AdbRelay] and the replacement is resolved once, within seconds.
|
||||||
|
*/
|
||||||
|
class AdbRelayService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var relay: AdbRelay? = null
|
||||||
|
|
||||||
|
@Volatile private var status: String = "starting"
|
||||||
|
@Volatile private var lastPosted: String? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
startForeground(NOTIFICATION_ID, notification("starting"))
|
||||||
|
val settings = Settings(this)
|
||||||
|
val r = AdbRelay(this) { msg ->
|
||||||
|
status = msg
|
||||||
|
notify(msg)
|
||||||
|
}
|
||||||
|
relay = r
|
||||||
|
r.start()
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val ep = r.lastEndpoint
|
||||||
|
if (ep != null) {
|
||||||
|
val wire = "${ep.host}:${ep.port}"
|
||||||
|
// Re-post on a heartbeat even when unchanged: the server stamps a received-at
|
||||||
|
// time, and a developer needs to tell "this endpoint is current" from "this
|
||||||
|
// endpoint is what the tablet saw before it went out of range".
|
||||||
|
val result = post(settings, ep)
|
||||||
|
status = if (result == null) {
|
||||||
|
lastPosted = wire
|
||||||
|
"reported $wire"
|
||||||
|
} else {
|
||||||
|
"found $wire, but reporting failed: $result"
|
||||||
|
}
|
||||||
|
notify(status)
|
||||||
|
}
|
||||||
|
delay(HEARTBEAT_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Posts one endpoint; returns null on success or a short reason on failure. */
|
||||||
|
private suspend fun post(settings: Settings, ep: AdbRelay.Endpoint): String? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (!settings.serverConfigured) return@withContext "no server enrolled"
|
||||||
|
runCatching {
|
||||||
|
ControlClient(settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER)
|
||||||
|
.reportAdbEndpoint(
|
||||||
|
credential = settings.serverCredential,
|
||||||
|
host = ep.host,
|
||||||
|
port = ep.port,
|
||||||
|
deviceName = Build.MODEL,
|
||||||
|
note = "echolot relay",
|
||||||
|
)
|
||||||
|
null
|
||||||
|
}.getOrElse { it.message?.take(120) ?: it.javaClass.simpleName }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
// Restarted by the system if it is killed: a relay that quietly does not come back after
|
||||||
|
// a low-memory kill is the failure mode this exists to avoid.
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
relay?.stop()
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notify(text: String) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm?.notify(NOTIFICATION_ID, notification(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notification(text: String): Notification {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// LOW: this is a status line for a tool the user deliberately started, not news.
|
||||||
|
val ch = NotificationChannel(CHANNEL, "adb relay", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
ch.description = "Reports this device's wireless-debug endpoint to the Echolot server"
|
||||||
|
nm?.createNotificationChannel(ch)
|
||||||
|
}
|
||||||
|
val open = android.app.PendingIntent.getActivity(
|
||||||
|
this, 0, Intent(this, MainActivity::class.java),
|
||||||
|
android.app.PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
return Notification.Builder(this, CHANNEL)
|
||||||
|
.setContentTitle("Echolot adb relay")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val CHANNEL = "adb-relay"
|
||||||
|
private const val NOTIFICATION_ID = 4711
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two minutes. The port rotates on roughly that cadence, and the freshness of the answer
|
||||||
|
* is the whole product — but this only re-posts a cached value, so it costs one small
|
||||||
|
* HTTPS request and never touches mDNS.
|
||||||
|
*/
|
||||||
|
private const val HEARTBEAT_MS = 120_000L
|
||||||
|
|
||||||
|
fun start(ctx: Context) {
|
||||||
|
val i = Intent(ctx, AdbRelayService::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
|
||||||
|
else ctx.startService(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(ctx: Context) {
|
||||||
|
ctx.stopService(Intent(ctx, AdbRelayService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,9 +43,28 @@ class MainActivity : ComponentActivity() {
|
|||||||
private val permissionLauncher =
|
private val permissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestRuntimePermissions()
|
requestRuntimePermissions()
|
||||||
|
resumeRelayIfEnabled()
|
||||||
|
liveIntent.value = intent
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
Surface(color = MaterialTheme.colorScheme.background) {
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
@@ -63,15 +82,105 @@ class MainActivity : ComponentActivity() {
|
|||||||
// An echolot://enroll link (QR scan, or a link the operator sent) opens the
|
// 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
|
// 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.
|
// sees the result rather than a form they still have to fill in.
|
||||||
val enrollUri = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.dataString
|
// 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) {
|
androidx.compose.runtime.LaunchedEffect(enrollUri) {
|
||||||
if (enrollUri != null) {
|
if (enrollUri != null) {
|
||||||
vm.enroll(enrollUri)
|
vm.enroll(enrollUri)
|
||||||
screen = Screen.SETTINGS
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A long run samples for minutes, and Android starts throttling timers and
|
||||||
|
// network access within moments of the screen going off — so a run left to
|
||||||
|
// itself would measure the device's power management rather than the network,
|
||||||
|
// and would do it silently. Held only while a run is in flight, and released
|
||||||
|
// on the way out.
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
androidx.compose.runtime.DisposableEffect(vm.state.running) {
|
||||||
|
view.keepScreenOn = vm.state.running
|
||||||
|
onDispose { view.keepScreenOn = false }
|
||||||
|
}
|
||||||
|
// 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) }
|
||||||
|
}
|
||||||
|
// Autorun stays a quick run: it is an unattended batch job driven over adb, and
|
||||||
|
// an automation that silently held the device for five minutes would be a
|
||||||
|
// surprise. `--es mode long` asks for the other one explicitly.
|
||||||
|
val autorunMode =
|
||||||
|
if (intent?.getStringExtra("mode") == "long") RunMode.LONG else RunMode.SHORT
|
||||||
androidx.compose.runtime.LaunchedEffect(autorun) {
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
if (autorun) vm.run(devUpload = true)
|
if (autorun) vm.run(autorunMode, devUpload = true)
|
||||||
}
|
}
|
||||||
// In autorun the app is a batch job: once the run is done AND the upload
|
// 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
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
@@ -101,15 +210,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
onApplyRetention = vm::applyRetention,
|
onApplyRetention = vm::applyRetention,
|
||||||
onDeleteAll = vm::deleteAllRuns,
|
onDeleteAll = vm::deleteAllRuns,
|
||||||
onPreviewUpload = {
|
onPreviewUpload = {
|
||||||
// Preview the newest run, since that is the one the user just made
|
// Straight from the archive: the newest run is the one the user just
|
||||||
// and the one they are deciding about.
|
// made and the one they are deciding about. Always shows something,
|
||||||
vm.state.history.firstOrNull()?.let { r ->
|
// even when there is nothing to preview yet.
|
||||||
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
|
lifecycleScope.launch { preview = vm.previewNewestRun() }
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onCheckServer = vm::checkServer,
|
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,
|
onEnroll = vm::enroll,
|
||||||
serverStatus = vm.state.archiveStatus,
|
serverStatus = vm.state.archiveStatus,
|
||||||
|
enrollStatus = vm.state.enrollStatus,
|
||||||
|
onRelayChange = { on ->
|
||||||
|
if (on) AdbRelayService.start(this@MainActivity)
|
||||||
|
else AdbRelayService.stop(this@MainActivity)
|
||||||
|
},
|
||||||
onBack = { screen = Screen.RUN },
|
onBack = { screen = Screen.RUN },
|
||||||
)
|
)
|
||||||
Screen.HISTORY -> HistoryScreen(
|
Screen.HISTORY -> HistoryScreen(
|
||||||
@@ -133,7 +258,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
Screen.RUN -> EcholotScreen(
|
Screen.RUN -> EcholotScreen(
|
||||||
state = vm.state,
|
state = vm.state,
|
||||||
onRun = { vm.run() },
|
longMinutes = vm.settings.longRunMinutes,
|
||||||
|
onRun = { mode -> vm.run(mode) },
|
||||||
onCancel = vm::cancel,
|
onCancel = vm::cancel,
|
||||||
onDeveloperOptions = {
|
onDeveloperOptions = {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -170,11 +296,29 @@ class MainActivity : ComponentActivity() {
|
|||||||
|
|
||||||
private fun requestRuntimePermissions() {
|
private fun requestRuntimePermissions() {
|
||||||
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
// Only so the relay's ongoing notification is visible. The service runs either way, but a
|
||||||
|
// foreground service the user cannot see is worse than one they can dismiss knowingly.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
perms.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
val missing = perms.filter {
|
val missing = perms.filter {
|
||||||
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restarts the relay if it was left on.
|
||||||
|
*
|
||||||
|
* A relay that silently fails to come back after a reboot or a process kill is worse than one
|
||||||
|
* that was never enabled: it is trusted right up to the moment it goes quiet, and the symptom
|
||||||
|
* is a stale endpoint that sends a developer to a port nothing is listening on.
|
||||||
|
*/
|
||||||
|
private fun resumeRelayIfEnabled() {
|
||||||
|
if (Settings(this).adbRelayEnabled) {
|
||||||
|
runCatching { AdbRelayService.start(this) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verdictColor(v: Verdict): Color = when (v) {
|
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||||
@@ -194,7 +338,8 @@ private fun statusColor(s: TestStatus): Color = when (s) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun EcholotScreen(
|
private fun EcholotScreen(
|
||||||
state: UiState,
|
state: UiState,
|
||||||
onRun: () -> Unit,
|
longMinutes: Int,
|
||||||
|
onRun: (RunMode) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
onShizukuAction: () -> Unit,
|
onShizukuAction: () -> Unit,
|
||||||
onDeveloperOptions: () -> Unit,
|
onDeveloperOptions: () -> Unit,
|
||||||
@@ -258,8 +403,36 @@ private fun EcholotScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The choice is made before the run, not after, because it is a choice about how long the
|
||||||
|
// user is willing to stand still — and because the two modes answer different questions.
|
||||||
|
var mode by remember { mutableStateOf(RunMode.SHORT) }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == RunMode.SHORT,
|
||||||
|
onClick = { mode = RunMode.SHORT },
|
||||||
|
enabled = !state.running,
|
||||||
|
label = { Text("Quick") },
|
||||||
|
)
|
||||||
|
FilterChip(
|
||||||
|
selected = mode == RunMode.LONG,
|
||||||
|
onClick = { mode = RunMode.LONG },
|
||||||
|
enabled = !state.running,
|
||||||
|
label = { Text("Long ($longMinutes min)") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
if (mode == RunMode.SHORT) {
|
||||||
|
"About 30 seconds. Describes how the network is configured right now."
|
||||||
|
} else {
|
||||||
|
"Listens for $longMinutes minutes while it measures. Finds what a quick run " +
|
||||||
|
"structurally cannot: links that drop and come back, signal that decays, " +
|
||||||
|
"loss that arrives in bursts."
|
||||||
|
},
|
||||||
|
fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
Button(onClick = onRun, enabled = !state.running) {
|
Button(onClick = { onRun(mode) }, enabled = !state.running) {
|
||||||
Text(if (state.running) "Running…" else "Run measurement")
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
}
|
}
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
@@ -280,25 +453,55 @@ private fun EcholotScreen(
|
|||||||
|
|
||||||
if (state.running) {
|
if (state.running) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
val frac = if (state.stepsTotal > 0)
|
// In a long run the window is the run: once the battery is done, four minutes of
|
||||||
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
// listening remain, and a bar driven by the step count would sit at 100 % through
|
||||||
|
// all of it — which reads as an app that has hung, not one that is working.
|
||||||
|
val listening = state.windowTotalS > 0
|
||||||
|
val frac = when {
|
||||||
|
listening -> state.windowElapsedS.toFloat() / state.windowTotalS
|
||||||
|
state.stepsTotal > 0 -> state.stepsDone.toFloat() / state.stepsTotal
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
progress = { frac },
|
progress = { frac.coerceIn(0f, 1f) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
if (listening) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
"listening ${clock(state.windowElapsedS)} of ${clock(state.windowTotalS)}",
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${clock(state.windowTotalS - state.windowElapsedS)} left",
|
||||||
|
fontSize = 12.sp, modifier = Modifier.weight(1f),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// Still shown during a long run's listening phase, where it stops at the last
|
||||||
|
// test: the battery's progress is real information, it is simply not the whole
|
||||||
|
// run any more.
|
||||||
Text(
|
Text(
|
||||||
if (state.stepsTotal > 0)
|
if (state.stepsTotal > 0)
|
||||||
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
|
"test ${(state.stepsDone + 1).coerceAtMost(state.stepsTotal)} of ${state.stepsTotal}"
|
||||||
|
else "starting",
|
||||||
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||||
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||||
if (state.etaSeconds > 0) {
|
if (!listening && state.etaSeconds > 0) {
|
||||||
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (listening) {
|
||||||
|
Text(
|
||||||
|
"Cancelling keeps what has been collected so far.",
|
||||||
|
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,11 +511,57 @@ private fun EcholotScreen(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Results(doc: MeasurementDocument) {
|
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 this app permission to send on $blocked, so they went " +
|
||||||
|
"unmeasured. A connected VPN is the usual cause; the refusal can also " +
|
||||||
|
"outlast one. Everything else in this run is unaffected.")
|
||||||
|
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
|
val summary = doc.summary
|
||||||
if (summary != null) {
|
if (summary != null) {
|
||||||
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
// Which question this document answers. A green light from a quick run does not
|
||||||
|
// mean the same thing as a green light from a long one, and the report should not
|
||||||
|
// let the two look identical.
|
||||||
|
Text(
|
||||||
|
if (doc.run.mode == RunMode.LONG) {
|
||||||
|
"long run — the network was watched continuously as well as probed"
|
||||||
|
} else {
|
||||||
|
"quick run — a snapshot; nothing here rules out an intermittent fault"
|
||||||
|
},
|
||||||
|
color = Color.White, fontSize = 12.sp,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FlowCategories(summary.categories)
|
FlowCategories(summary.categories)
|
||||||
@@ -418,6 +667,12 @@ private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** m:ss — minutes are how a five-minute wait is read; "247s left" is a number to convert. */
|
||||||
|
private fun clock(seconds: Int): String {
|
||||||
|
val s = seconds.coerceAtLeast(0)
|
||||||
|
return "${s / 60}:${"%02d".format(s % 60)}"
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Dot(color: Color) {
|
private fun Dot(color: Color) {
|
||||||
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
||||||
|
|||||||
@@ -80,8 +80,16 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
|
|
||||||
private fun client() = ControlClient(
|
private fun client() = ControlClient(
|
||||||
settings.serverUrl, setOf(settings.serverPin), BuildConfig.APP_SEMVER,
|
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
|
* 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.
|
* willing to accept runs. Lets the user find out in settings rather than from a failed run.
|
||||||
@@ -90,6 +98,10 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
if (!settings.serverConfigured) return "Fill in the server URL, pin and credential first."
|
||||||
return try {
|
return try {
|
||||||
val profile = client().profile(settings.serverCredential)
|
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 compat = Compat.check(profile, BuildConfig.APP_SEMVER)
|
||||||
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
val head = "${profile.name} · server ${profile.serverVersion} · " +
|
||||||
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
"protocol ${profile.compat.protocolVersion.ifBlank { "unstated" }}"
|
||||||
@@ -115,6 +127,36 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
|
* no credential — fails later, somewhere else, with an error that points at the wrong thing.
|
||||||
* Blocking; callers run it off the main thread.
|
* 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 {
|
fun enroll(link: String, deviceName: String?): String {
|
||||||
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
|
val parsed = app.echo_lot.protocol.EnrollmentLink.parse(link)
|
||||||
?: return "That does not look like an Echolot enrollment link. It should start with " +
|
?: return "That does not look like an Echolot enrollment link. It should start with " +
|
||||||
@@ -122,7 +164,11 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
return try {
|
return try {
|
||||||
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
val enrolled = parsed.redeem(deviceName, BuildConfig.APP_SEMVER)
|
||||||
val compat = Compat.check(enrolled.profile, 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.serverUrl = enrolled.controlUrl
|
||||||
|
settings.serverPublicUrl = enrolled.publicUrl
|
||||||
settings.serverPin = enrolled.pin
|
settings.serverPin = enrolled.pin
|
||||||
settings.serverCredential = enrolled.credential
|
settings.serverCredential = enrolled.credential
|
||||||
val head = "Enrolled with ${enrolled.profile.name} " +
|
val head = "Enrolled with ${enrolled.profile.name} " +
|
||||||
@@ -150,6 +196,9 @@ class RunStore(context: Context, private val settings: Settings) {
|
|||||||
return try {
|
return try {
|
||||||
val client = client()
|
val client = client()
|
||||||
val profile = client.profile(settings.serverCredential)
|
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
|
// Compatibility before policy: an incompatible server may well advertise an upload
|
||||||
// policy it would never actually apply to us.
|
// policy it would never actually apply to us.
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import app.echo_lot.probe.RouterIdentityProbe
|
|||||||
import app.echo_lot.shizuku.ShizukuAvailability
|
import app.echo_lot.shizuku.ShizukuAvailability
|
||||||
import app.echo_lot.shizuku.ShizukuProbe
|
import app.echo_lot.shizuku.ShizukuProbe
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -38,10 +40,32 @@ data class UiState(
|
|||||||
val stepsDone: Int = 0,
|
val stepsDone: Int = 0,
|
||||||
val stepsTotal: Int = 0,
|
val stepsTotal: Int = 0,
|
||||||
val etaSeconds: Int = 0,
|
val etaSeconds: Int = 0,
|
||||||
|
/** Which mode the run in progress (or the last one) used. */
|
||||||
|
val mode: RunMode = RunMode.SHORT,
|
||||||
|
/**
|
||||||
|
* The listening window, in seconds. Zero for a short run.
|
||||||
|
*
|
||||||
|
* A long run's step count stops being the honest progress measure the moment the battery is
|
||||||
|
* done and four minutes of listening remain: the bar would sit at 100 % while the run carried
|
||||||
|
* on, which reads as a hung app. Over a window, elapsed-of-total is the truth.
|
||||||
|
*/
|
||||||
|
val windowElapsedS: Int = 0,
|
||||||
|
val windowTotalS: Int = 0,
|
||||||
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
|
||||||
val archiveStatus: String? = null,
|
val archiveStatus: String? = null,
|
||||||
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
|
||||||
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
|
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). */
|
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
|
||||||
val shizukuNotice: String? = null,
|
val shizukuNotice: String? = null,
|
||||||
val shizukuReady: Boolean = false,
|
val shizukuReady: Boolean = false,
|
||||||
@@ -49,6 +73,18 @@ data class UiState(
|
|||||||
val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED,
|
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;
|
* 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
|
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
|
||||||
@@ -80,8 +116,36 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
override fun onCleared() {
|
||||||
stopShizukuObserver()
|
stopShizukuObserver()
|
||||||
|
// A NetworkCallback outlives the object that registered it: the system holds the reference,
|
||||||
|
// so a ViewModel dying mid-run with listeners still up leaks one for the life of the
|
||||||
|
// process. viewModelScope is already cancelled by now, hence a detached scope purely to
|
||||||
|
// hang up — its results are discarded, only the unregistration matters.
|
||||||
|
val leftovers = activeCollectors
|
||||||
|
activeCollectors = emptyList()
|
||||||
|
if (leftovers.isNotEmpty()) {
|
||||||
|
kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
leftovers.forEach { runCatching { it.stop() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
// Results collected so far. A cancelled run must still be able to show what it measured.
|
// Results collected so far. A cancelled run must still be able to show what it measured.
|
||||||
@@ -90,6 +154,19 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
private var runStartWall: String = ""
|
private var runStartWall: String = ""
|
||||||
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
private var runShizukuOk = false
|
private var runShizukuOk = false
|
||||||
|
private var runConstraints = Constraints()
|
||||||
|
private var runMode: RunMode = RunMode.SHORT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listeners of the run in flight.
|
||||||
|
*
|
||||||
|
* Held on the ViewModel rather than inside `measure()` because [cancel] has to be able to reach
|
||||||
|
* them: the run job is dead by then, and what the listeners gathered up to that moment is the
|
||||||
|
* most valuable part of a long run that was cut short. They own their own coroutine scopes for
|
||||||
|
* the same reason — cancelling the run must stop the sampling without discarding the samples.
|
||||||
|
*/
|
||||||
|
private var activeCollectors: List<app.echo_lot.probe.Collector> = emptyList()
|
||||||
|
private var changeCollector: app.echo_lot.probe.NetworkChangeCollector? = null
|
||||||
|
|
||||||
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
private class RunIds : ProbeIds {
|
private class RunIds : ProbeIds {
|
||||||
@@ -105,13 +182,17 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
* from the user-facing upload so a debugging convenience can never be mistaken for, or
|
||||||
* silently satisfy, the consent-gated one.
|
* silently satisfy, the consent-gated one.
|
||||||
*/
|
*/
|
||||||
fun run(devUpload: Boolean = false) {
|
fun run(mode: RunMode = RunMode.SHORT, devUpload: Boolean = false) {
|
||||||
if (state.running) return
|
if (state.running) return
|
||||||
collected.clear()
|
collected.clear()
|
||||||
|
runConstraints = Constraints()
|
||||||
|
runMode = mode
|
||||||
|
val windowS = if (mode == RunMode.LONG) settings.longRunMinutes * 60 else 0
|
||||||
state = state.copy(running = true, currentStep = "starting", document = null,
|
state = state.copy(running = true, currentStep = "starting", document = null,
|
||||||
uploadStatus = null, archiveStatus = null)
|
uploadStatus = null, archiveStatus = null,
|
||||||
|
mode = mode, windowElapsedS = 0, windowTotalS = windowS)
|
||||||
runJob = viewModelScope.launch {
|
runJob = viewModelScope.launch {
|
||||||
val doc = withContext(Dispatchers.IO) { measure() }
|
val doc = withContext(Dispatchers.IO) { measure(mode) }
|
||||||
|
|
||||||
step("archiving")
|
step("archiving")
|
||||||
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
|
||||||
@@ -125,7 +206,13 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
if (devUpload) {
|
if (devUpload) {
|
||||||
state = state.copy(currentStep = "uploading report")
|
state = state.copy(currentStep = "uploading report")
|
||||||
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||||
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
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) {
|
if (archived != null && settings.autoUpload) {
|
||||||
state = state.copy(currentStep = "uploading to server")
|
state = state.copy(currentStep = "uploading to server")
|
||||||
@@ -136,6 +223,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
state = UiState(
|
state = UiState(
|
||||||
running = false, currentStep = null, document = doc,
|
running = false, currentStep = null, document = doc,
|
||||||
uploadStatus = status, archiveStatus = archiveStatus,
|
uploadStatus = status, archiveStatus = archiveStatus,
|
||||||
|
mode = mode,
|
||||||
history = withContext(Dispatchers.IO) { store.list() },
|
history = withContext(Dispatchers.IO) { store.list() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,17 +282,109 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
store.read(id)?.let { store.redactedForUpload(it) }
|
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()
|
fun archivedBytes(): Long = store.totalBytes()
|
||||||
|
|
||||||
/** Counted from the archive itself, not from [UiState.history], which is empty until the
|
/** 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. */
|
* history screen has been opened - the two disagreeing read as data loss. */
|
||||||
fun archivedRunCount(): Int = store.list().size
|
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. */
|
/** Redeems an enrollment link, from a paste or from an echolot:// deep link. */
|
||||||
fun enroll(link: String, deviceName: String? = android.os.Build.MODEL) {
|
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 {
|
viewModelScope.launch {
|
||||||
state = state.copy(archiveStatus = "enrolling …")
|
state = state.copy(enrollStatus = "Enrolling …")
|
||||||
state = state.copy(archiveStatus = withContext(Dispatchers.IO) { store.enroll(link, deviceName) })
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,45 +411,161 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
/**
|
/**
|
||||||
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
|
* 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.
|
* a partial run is for the person looking at the screen, not for the record.
|
||||||
|
*
|
||||||
|
* Cancelling a long run must still hand back what the listeners heard — two minutes of watching
|
||||||
|
* is worth reporting, and throwing it away because the user did not wait for the third would be
|
||||||
|
* the worst possible answer to "I've seen enough". The harvest runs on [viewModelScope] rather
|
||||||
|
* than in the (now cancelled) run job, and the listeners' own scopes are what kept their data
|
||||||
|
* alive long enough to collect.
|
||||||
*/
|
*/
|
||||||
fun cancel() {
|
fun cancel() {
|
||||||
if (!state.running) return
|
if (!state.running || cancelling) return
|
||||||
|
cancelling = true
|
||||||
runJob?.cancel()
|
runJob?.cancel()
|
||||||
val doc = buildDocument(collected.toList())
|
state = state.copy(currentStep = "stopping listeners")
|
||||||
state = UiState(
|
viewModelScope.launch {
|
||||||
running = false, currentStep = null, document = doc,
|
withContext(Dispatchers.IO) { stopCollectors() }
|
||||||
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
val doc = buildDocument(collected.toList())
|
||||||
archiveStatus = "partial run — not archived",
|
cancelling = false
|
||||||
history = state.history,
|
state = UiState(
|
||||||
)
|
running = false, currentStep = null, document = doc,
|
||||||
|
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||||
|
archiveStatus = "partial run — not archived",
|
||||||
|
mode = runMode,
|
||||||
|
history = state.history,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun measure(): MeasurementDocument {
|
private var cancelling = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops every listener, folds its Test into the run, and attaches the observed network changes
|
||||||
|
* to `networks[]`. Idempotent — the normal path and the cancel path both call it, and only the
|
||||||
|
* first does anything.
|
||||||
|
*/
|
||||||
|
private suspend fun stopCollectors(): List<Test> {
|
||||||
|
val running = activeCollectors
|
||||||
|
activeCollectors = emptyList()
|
||||||
|
val out = ArrayList<Test>()
|
||||||
|
for (c in running) {
|
||||||
|
val t = runCatching { c.stop() }.getOrNull() ?: continue
|
||||||
|
out.add(t)
|
||||||
|
collected.add(t)
|
||||||
|
}
|
||||||
|
changeCollector?.let { watcher ->
|
||||||
|
changeCollector = null
|
||||||
|
val byNetwork = runCatching { watcher.changesByNetwork() }.getOrDefault(emptyMap())
|
||||||
|
if (byNetwork.isNotEmpty()) {
|
||||||
|
runNetworks = runNetworks.map { n -> n.copy(changes = byNetwork[n.id] ?: n.changes) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measure(mode: RunMode): MeasurementDocument = coroutineScope {
|
||||||
val ctx = getApplication<Application>()
|
val ctx = getApplication<Application>()
|
||||||
val ids = RunIds().also { runIds = it }
|
val ids = RunIds().also { runIds = it }
|
||||||
val startWall = Instant.now().toString().also { runStartWall = it }
|
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||||
|
val windowMs = if (mode == RunMode.LONG) settings.longRunMinutes * 60_000L else 0L
|
||||||
|
|
||||||
step("reading networks")
|
step("reading networks")
|
||||||
val entries = NetworkInventory.snapshot(ctx)
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
val networks = entries.map { it.model }.also { runNetworks = it }
|
val networks = entries.map { it.model }.also { runNetworks = it }
|
||||||
|
|
||||||
val probes: List<Probe> = listOf(
|
// 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)
|
||||||
|
|
||||||
|
// Listeners first, at t=0, so the battery itself runs *inside* the observed window: a link
|
||||||
|
// that drops while the DNS probe is timing out is then recorded as a drop rather than
|
||||||
|
// guessed at from a failure.
|
||||||
|
var ticker: kotlinx.coroutines.Job? = null
|
||||||
|
if (mode == RunMode.LONG) {
|
||||||
|
step("starting listeners")
|
||||||
|
val watcher = app.echo_lot.probe.NetworkChangeCollector(entries).also { changeCollector = it }
|
||||||
|
val collectors = listOf<app.echo_lot.probe.Collector>(
|
||||||
|
watcher,
|
||||||
|
app.echo_lot.probe.WifiSignalCollector(entries),
|
||||||
|
app.echo_lot.probe.PingSeriesCollector(),
|
||||||
|
// mDNS is a listener wearing a probe's clothes, so in long mode it listens for the
|
||||||
|
// window instead of blocking the battery for it. Two seconds short of the window,
|
||||||
|
// so it finishes just before everything is stopped rather than just after.
|
||||||
|
app.echo_lot.probe.ProbeCollector(
|
||||||
|
app.echo_lot.probe.MdnsInventoryProbe(
|
||||||
|
listenMs = (windowMs - 2_000).coerceAtLeast(10_000)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
// The rest of what a segment says about itself unprompted. All passive listeners,
|
||||||
|
// which is exactly why they belong to long mode: the announcements are periodic
|
||||||
|
// and sparse, so a thirty-second run mostly hears silence and would report an
|
||||||
|
// empty network as confidently as a quiet one.
|
||||||
|
//
|
||||||
|
// Searches are paced across the window rather than fired at the start: a device
|
||||||
|
// that was asleep for the first minute still gets asked. NetBIOS is included
|
||||||
|
// knowing it will usually report `unsupported` — UDP 137 is privileged, so the
|
||||||
|
// app tier cannot bind it — because a recorded reason beats an absent test, and
|
||||||
|
// the decoder is ready for the Shizuku tier.
|
||||||
|
app.echo_lot.probe.SsdpCollector(
|
||||||
|
searchIntervalMs = (windowMs / 5).coerceAtLeast(30_000),
|
||||||
|
),
|
||||||
|
app.echo_lot.probe.LlmnrCollector(),
|
||||||
|
app.echo_lot.probe.NetbiosCollector(),
|
||||||
|
app.echo_lot.probe.WsdCollector(),
|
||||||
|
)
|
||||||
|
activeCollectors = collectors
|
||||||
|
for (c in collectors) runCatching { c.start(ctx, ids) }
|
||||||
|
// One ticker for the whole run: the battery does not report progress by the second, and
|
||||||
|
// without this the elapsed/remaining line would freeze for as long as the slowest probe.
|
||||||
|
ticker = launch {
|
||||||
|
while (isActive) {
|
||||||
|
val elapsedS = (ids.monoNs() / 1_000_000_000L).toInt()
|
||||||
|
state = state.copy(
|
||||||
|
windowElapsedS = elapsedS.coerceAtMost((windowMs / 1000).toInt()),
|
||||||
|
windowTotalS = (windowMs / 1000).toInt(),
|
||||||
|
)
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val probes: List<Probe> = listOfNotNull(
|
||||||
LinkSnapshotProbe(entries),
|
LinkSnapshotProbe(entries),
|
||||||
RouterIdentityProbe(entries),
|
RouterIdentityProbe(entries),
|
||||||
IcmpProbe(entries, v6 = false),
|
IcmpProbe(entries, v6 = false),
|
||||||
IcmpProbe(entries, v6 = true),
|
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(),
|
||||||
|
// Absent from a long run's battery: it runs there as a collector for the whole window
|
||||||
|
// instead, and running it in both places would query the same services twice.
|
||||||
|
if (mode == RunMode.LONG) null else app.echo_lot.probe.MdnsInventoryProbe(),
|
||||||
CaptivePortalProbe(entries),
|
CaptivePortalProbe(entries),
|
||||||
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
// the reference deployment until profiles/enrollment land in the UI.
|
// the reference deployment until profiles/enrollment land in the UI.
|
||||||
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
|
// Both target whatever server this device is enrolled with, not the deployment the
|
||||||
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
// 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
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
|
// the bar reflects the whole run. Estimates prefer what THIS device measured on recent
|
||||||
val shizukuEstimateMs = 8_000L
|
// 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
|
val totalSteps = probes.size + 1
|
||||||
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
|
var remainingMs = probes.sumOf { estimateOf(it) } + shizukuEstimateMs
|
||||||
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
||||||
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
||||||
|
|
||||||
@@ -289,24 +585,52 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
tests.add(result); collected.add(result)
|
tests.add(result); collected.add(result)
|
||||||
remainingMs -= p.estimatedMs
|
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.
|
// 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)
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
val shizukuTest = try {
|
val shizukuT0 = System.nanoTime()
|
||||||
|
val shizukuTests = try {
|
||||||
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
Test(
|
listOf(Test(
|
||||||
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
|
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
|
||||||
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
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)
|
||||||
}
|
}
|
||||||
tests.add(shizukuTest); collected.add(shizukuTest)
|
|
||||||
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
|
||||||
|
|
||||||
return buildDocument(tests)
|
if (mode == RunMode.LONG) {
|
||||||
|
// The battery finishing is not the run finishing. Everything the long mode exists for
|
||||||
|
// happens in the minutes after this point, so the run waits the window out — in one
|
||||||
|
// second steps, because `delay` is what makes a five-minute wait cancellable.
|
||||||
|
while (true) {
|
||||||
|
val elapsedMs = ids.monoNs() / 1_000_000
|
||||||
|
val remainingS = ((windowMs - elapsedMs + 999) / 1000).toInt()
|
||||||
|
if (remainingS <= 0) break
|
||||||
|
step("listening · ${remainingS}s left", done = totalSteps, total = totalSteps, etaMs = windowMs - elapsedMs)
|
||||||
|
kotlinx.coroutines.delay(minOf(1000L, windowMs - elapsedMs))
|
||||||
|
}
|
||||||
|
step("collecting listeners")
|
||||||
|
tests.addAll(stopCollectors())
|
||||||
|
}
|
||||||
|
// Before the enclosing coroutineScope waits for its children, or the run would never end.
|
||||||
|
ticker?.cancel()
|
||||||
|
|
||||||
|
buildDocument(tests)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
||||||
@@ -314,7 +638,7 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val findings = deriveFindings(tests, runNetworks)
|
val findings = deriveFindings(tests, runNetworks)
|
||||||
return MeasurementDocument(
|
return MeasurementDocument(
|
||||||
run = Run(
|
run = Run(
|
||||||
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
id = runIds.uuid(), trigger = Trigger.MANUAL, mode = runMode, startedAt = runStartWall,
|
||||||
endedAt = Instant.now().toString(),
|
endedAt = Instant.now().toString(),
|
||||||
clock = Clock(monoOriginWall = runStartWall),
|
clock = Clock(monoOriginWall = runStartWall),
|
||||||
app = AppInfo(
|
app = AppInfo(
|
||||||
@@ -325,31 +649,221 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
),
|
),
|
||||||
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||||
|
constraints = runConstraints,
|
||||||
),
|
),
|
||||||
networks = runNetworks,
|
networks = runNetworks,
|
||||||
tests = tests,
|
tests = tests,
|
||||||
findings = findings,
|
findings = findings,
|
||||||
summary = Verdicts.derive(tests, findings),
|
summary = Verdicts.derive(tests, findings, runConstraints),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
* Was IPv6 provisioned on the network a test actually ran over?
|
||||||
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
*
|
||||||
|
* 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>): Boolean =
|
private fun ipv6Provisioned(
|
||||||
networks.any { n ->
|
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 ->
|
n.link.addresses.any { a ->
|
||||||
a.addr.contains(':') &&
|
a.addr.contains(':') &&
|
||||||
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
||||||
!a.addr.startsWith("::1")
|
!a.addr.startsWith("::1")
|
||||||
} || n.link.routes.any { it.dst == "::/0" }
|
} || 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. */
|
/** 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> {
|
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
||||||
val out = ArrayList<Finding>()
|
val out = ArrayList<Finding>()
|
||||||
val ids = RunIds()
|
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), so nothing in this run says anything about it. The usual " +
|
||||||
|
"cause is a VPN — Android walls off the networks beneath a tunnel — " +
|
||||||
|
"but the refusal can also outlast one, and some networks are " +
|
||||||
|
"reserved for the system entirely. If no VPN is connected, this is " +
|
||||||
|
"a restriction on the app rather than a fault in the network.")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// The one finding only a long run can reach. Derived from networks[].changes[] rather than
|
||||||
|
// from the watcher's evidence, because the changes are the schema's own record of what
|
||||||
|
// happened (§4) and re-parsing the test would be a second, divergent reading of it. A short
|
||||||
|
// run has an empty changes[] and therefore never gets here — which is correct, not a gap:
|
||||||
|
// it did not watch, so it has nothing to say either way.
|
||||||
|
val watchTest = tests.firstOrNull {
|
||||||
|
it.type == TestType.LINK_IP_MONITOR && it.tier == Tier.APP
|
||||||
|
}
|
||||||
|
if (watchTest != null) {
|
||||||
|
for (n in networks) {
|
||||||
|
val cycles = NetworkChanges.flapCyclesOf(n.changes)
|
||||||
|
if (cycles < 1) continue
|
||||||
|
val where = n.iface?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(),
|
||||||
|
code = FindingRegistry.LINK_FLAPPING.code,
|
||||||
|
category = FindingRegistry.LINK_FLAPPING.category,
|
||||||
|
// Escalated on repetition: once is a hiccup worth knowing about, three
|
||||||
|
// times in one window is the reason someone's calls keep dropping.
|
||||||
|
severity = if (cycles >= 3) Severity.HIGH else FindingRegistry.LINK_FLAPPING.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
|
title = if (cycles == 1) "$where dropped and came back during the run"
|
||||||
|
else "$where dropped and came back $cycles times during the run",
|
||||||
|
description = "A listener watched this link for the whole measurement and " +
|
||||||
|
"saw it go away and return " +
|
||||||
|
(if (cycles == 1) "once" else "$cycles times") + ". Every one-shot " +
|
||||||
|
"test in this run may still have passed — the probes on either side of " +
|
||||||
|
"a gap succeed — so this is the kind of fault a quick measurement " +
|
||||||
|
"cannot find. Connections in flight are dropped each time it happens: " +
|
||||||
|
"calls end, downloads stall, and anything long-lived reconnects. On " +
|
||||||
|
"wifi the usual causes are a weak or contended channel, band steering, " +
|
||||||
|
"or an access point restarting; on cellular, handovers at the edge of " +
|
||||||
|
"coverage. The timeline of every change is in networks[].changes[].",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(watchTest.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
for (t in tests) {
|
||||||
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
|
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
|
||||||
val ev = t.evidence?.toString() ?: ""
|
val ev = t.evidence?.toString() ?: ""
|
||||||
@@ -417,31 +931,185 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
if (t.type == TestType.DNS_RESOLVER && t.status == TestStatus.OK) {
|
||||||
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
|
// One finding per network: on a phone the wifi resolver can be wedged while
|
||||||
// and that is not a defect. What IS a defect is IPv6 that the network claims to
|
// cellular is fine, and "DNS is broken" would be wrong about half the device.
|
||||||
// provide (a global address or a default route from RA/DHCPv6) but that does not
|
val ev = t.evidence
|
||||||
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
|
if (ev != null) {
|
||||||
// depends on whether v6 was provisioned at all.
|
for ((_, v) in ev) {
|
||||||
if (ipv6Provisioned(networks)) {
|
val o = v as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
out.add(
|
fun str(k: String) =
|
||||||
Finding(
|
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||||
id = ids.uuid(), code = FindingRegistry.V6_BROKEN.code,
|
val verdict = str("verdict")
|
||||||
category = FindingRegistry.V6_BROKEN.category,
|
val ref0 = str("network_ref")
|
||||||
severity = FindingRegistry.V6_BROKEN.severity, confidence = Confidence.HIGH,
|
val iface0 = networks.firstOrNull { it.id == ref0 }?.iface
|
||||||
title = "IPv6 is configured but not working",
|
?.takeIf { it.isNotBlank() } ?: "this network"
|
||||||
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
|
if (verdict == "search domain swallows queries") {
|
||||||
evidenceRefs = listOf(EvidenceRef(t.id)),
|
// 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) },
|
||||||
)
|
)
|
||||||
} else {
|
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(
|
out.add(
|
||||||
Finding(
|
Finding(
|
||||||
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
id = ids.uuid(), code = FindingRegistry.V6_NOT_OFFERED.code,
|
||||||
category = FindingRegistry.V6_NOT_OFFERED.category,
|
category = FindingRegistry.V6_NOT_OFFERED.category,
|
||||||
severity = FindingRegistry.V6_NOT_OFFERED.severity, confidence = Confidence.HIGH,
|
severity = FindingRegistry.V6_NOT_OFFERED.severity,
|
||||||
|
confidence = Confidence.HIGH,
|
||||||
title = "IPv4-only network (no IPv6 offered)",
|
title = "IPv4-only network (no IPv6 offered)",
|
||||||
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
|
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)),
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -457,4 +1125,9 @@ class RunViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,65 @@ class Settings(context: Context) {
|
|||||||
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ---- measurement -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a long run listens, in minutes. Offered as 1 / 5 / 15.
|
||||||
|
*
|
||||||
|
* 5 is the default because it is the shortest window in which the things long mode exists to
|
||||||
|
* catch — a link that flaps, a signal that decays as someone walks around, loss that comes in
|
||||||
|
* bursts — have a fair chance of happening at least twice. One minute is for checking that the
|
||||||
|
* mode works at all; fifteen is for chasing something already suspected.
|
||||||
|
*
|
||||||
|
* Clamped rather than trusted: a zero-minute long run would produce a document claiming a
|
||||||
|
* window it never watched, which is the one thing `run.mode` exists to prevent.
|
||||||
|
*/
|
||||||
|
var longRunMinutes: Int
|
||||||
|
get() = prefs.getInt(LONG_RUN_MINUTES, 5).coerceIn(1, 60)
|
||||||
|
set(v) = prefs.edit().putInt(LONG_RUN_MINUTES, v.coerceIn(1, 60)).apply()
|
||||||
|
|
||||||
|
// ---- dev relay -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this device relays adbd's wireless-debug endpoint to the enrolled server.
|
||||||
|
*
|
||||||
|
* Off by default and never implied by anything else: it publishes where this device can be
|
||||||
|
* reached for debugging, which is a decision rather than a side effect. Intended for a spare
|
||||||
|
* device parked on a test network — see AdbRelay for why the OnePlus is a poor host for it.
|
||||||
|
*/
|
||||||
|
var adbRelayEnabled: Boolean
|
||||||
|
get() = prefs.getBoolean(ADB_RELAY, false)
|
||||||
|
set(v) = prefs.edit().putBoolean(ADB_RELAY, v).apply()
|
||||||
|
|
||||||
|
// ---- 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 ----------------------------------------------------------------------
|
// ---- upload ----------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,6 +156,41 @@ class Settings(context: Context) {
|
|||||||
get() = prefs.getString(SERVER_PIN, "") ?: ""
|
get() = prefs.getString(SERVER_PIN, "") ?: ""
|
||||||
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
|
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
|
var serverCredential: String
|
||||||
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
get() = prefs.getString(SERVER_CRED, "") ?: ""
|
||||||
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
|
||||||
@@ -104,6 +198,58 @@ class Settings(context: Context) {
|
|||||||
val serverConfigured: Boolean
|
val serverConfigured: Boolean
|
||||||
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
|
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) {
|
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()
|
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
|
||||||
}
|
}
|
||||||
@@ -120,5 +266,16 @@ class Settings(context: Context) {
|
|||||||
const val SERVER_URL = "server_url"
|
const val SERVER_URL = "server_url"
|
||||||
const val SERVER_PIN = "server_pin"
|
const val SERVER_PIN = "server_pin"
|
||||||
const val SERVER_CRED = "server_credential"
|
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."
|
||||||
|
const val ADB_RELAY = "adb_relay_enabled"
|
||||||
|
const val LONG_RUN_MINUTES = "long_run_minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,18 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -30,6 +34,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import app.echo_lot.privacy.PrivacyLevel
|
import app.echo_lot.privacy.PrivacyLevel
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,8 +53,14 @@ fun SettingsScreen(
|
|||||||
onDeleteAll: () -> Unit,
|
onDeleteAll: () -> Unit,
|
||||||
onPreviewUpload: () -> Unit,
|
onPreviewUpload: () -> Unit,
|
||||||
onCheckServer: () -> Unit,
|
onCheckServer: () -> Unit,
|
||||||
|
accountName: String,
|
||||||
|
onSignIn: () -> Unit,
|
||||||
|
onSignOut: () -> Unit,
|
||||||
onEnroll: (String) -> Unit,
|
onEnroll: (String) -> Unit,
|
||||||
serverStatus: String?,
|
serverStatus: String?,
|
||||||
|
enrollStatus: String?,
|
||||||
|
/** Starts or stops the adb relay service; the toggle only records the preference. */
|
||||||
|
onRelayChange: (Boolean) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
// SharedPreferences is not observable, so mirror each value into Compose state and write
|
||||||
@@ -58,13 +69,28 @@ fun SettingsScreen(
|
|||||||
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
|
||||||
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
|
||||||
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
|
||||||
|
var longMinutes by remember { mutableStateOf(settings.longRunMinutes) }
|
||||||
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
|
||||||
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
var privacy by remember { mutableStateOf(settings.privacyLevel) }
|
||||||
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
|
||||||
|
var relayOn by remember { mutableStateOf(settings.adbRelayEnabled) }
|
||||||
var enrollLink by remember { mutableStateOf("") }
|
var enrollLink by remember { mutableStateOf("") }
|
||||||
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
|
// 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 serverPin by remember { mutableStateOf(settings.serverPin) }
|
||||||
var serverCred by remember { mutableStateOf(settings.serverCredential) }
|
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(
|
Column(
|
||||||
Modifier.fillMaxWidth().safeDrawingPadding().verticalScroll(rememberScrollState()).padding(16.dp),
|
Modifier.fillMaxWidth().safeDrawingPadding().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||||
@@ -75,6 +101,39 @@ fun SettingsScreen(
|
|||||||
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
Text("Settings", style = MaterialTheme.typography.titleLarge)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- measurement ----
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Long runs", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
"How long a long run keeps listening. The measurements themselves take about " +
|
||||||
|
"30 seconds either way; the rest of the window is spent watching for " +
|
||||||
|
"things that only happen sometimes.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
for (minutes in listOf(1, 5, 15)) {
|
||||||
|
FilterChip(
|
||||||
|
selected = longMinutes == minutes,
|
||||||
|
onClick = { longMinutes = minutes; settings.longRunMinutes = minutes },
|
||||||
|
label = { Text("$minutes min") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
when (longMinutes) {
|
||||||
|
1 -> "Barely longer than a quick run — enough to confirm the listeners " +
|
||||||
|
"work, rarely enough to catch anything intermittent."
|
||||||
|
15 -> "For a fault you already suspect and have to prove. Keep the screen " +
|
||||||
|
"on and the device where the problem happens."
|
||||||
|
else -> "Long enough for a link that drops every couple of minutes to do " +
|
||||||
|
"it at least once, short enough to wait out."
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- archive ----
|
// ---- archive ----
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -130,18 +189,59 @@ fun SettingsScreen(
|
|||||||
}
|
}
|
||||||
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
|
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(
|
Toggle(
|
||||||
label = "Stable pseudonyms across runs",
|
label = "Stable pseudonyms across runs",
|
||||||
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
|
detail = if (privacy == PrivacyLevel.FULL) {
|
||||||
"each time). It also links your uploads together, so leave it off on a " +
|
"Not used at this level — nothing is pseudonymized, so there is nothing " +
|
||||||
"server you don't run yourself.",
|
"to keep stable. Choose balanced or strict to use this."
|
||||||
checked = stableSalt,
|
} 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 }
|
) { stableSalt = it; settings.stableSalt = it }
|
||||||
|
|
||||||
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
|
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 ----
|
// ---- upload ----
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
@@ -173,17 +273,38 @@ fun SettingsScreen(
|
|||||||
onClick = {
|
onClick = {
|
||||||
onEnroll(enrollLink)
|
onEnroll(enrollLink)
|
||||||
enrollLink = "" // spent either way; leaving it around invites a retry
|
enrollLink = "" // spent either way; leaving it around invites a retry
|
||||||
serverUrl = settings.serverUrl
|
|
||||||
serverPin = settings.serverPin
|
|
||||||
serverCred = settings.serverCredential
|
|
||||||
},
|
},
|
||||||
enabled = enrollLink.isNotBlank(),
|
enabled = enrollLink.isNotBlank(),
|
||||||
) { Text("Enroll") }
|
) { 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(
|
OutlinedTextField(
|
||||||
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
|
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(),
|
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(
|
OutlinedTextField(
|
||||||
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
|
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
|
||||||
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
|
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
|
||||||
@@ -210,6 +331,52 @@ fun SettingsScreen(
|
|||||||
serverStatus?.let {
|
serverStatus?.let {
|
||||||
Text(it, style = MaterialTheme.typography.bodySmall)
|
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(
|
Text(
|
||||||
"This app is ${BuildConfig.APP_SEMVER} and speaks probe protocol " +
|
"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.PROTOCOL_VERSION}. It works with servers " +
|
||||||
@@ -218,6 +385,39 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dev relay ------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Last, and deliberately plain: this is scaffolding for driving a test device, not a
|
||||||
|
// measurement. It publishes where this device can be reached over adb, which is why it
|
||||||
|
// is off until someone decides otherwise.
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("Developer relay", style = MaterialTheme.typography.titleMedium)
|
||||||
|
Toggle(
|
||||||
|
label = "Relay this device's adb endpoint",
|
||||||
|
detail = "Watches adbd's own mDNS announcement and reports host:port to the " +
|
||||||
|
"enrolled server, so a developer on another network can reach this " +
|
||||||
|
"device — mDNS does not cross subnets, and the port rotates every few " +
|
||||||
|
"minutes. Runs in the foreground with a notification while on.",
|
||||||
|
checked = relayOn,
|
||||||
|
enabled = settings.serverConfigured,
|
||||||
|
onChange = { on ->
|
||||||
|
relayOn = on
|
||||||
|
settings.adbRelayEnabled = on
|
||||||
|
onRelayChange(on)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!settings.serverConfigured) {
|
||||||
|
Text(
|
||||||
|
"Needs an enrolled server: the report is authenticated with this " +
|
||||||
|
"device's credential.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LocalContentColor.current.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,13 +437,22 @@ private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
|
private fun Toggle(
|
||||||
|
label: String,
|
||||||
|
detail: String,
|
||||||
|
checked: Boolean,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
// Dimmed together with the switch, so "this does nothing right now" reads at a glance
|
||||||
Text(detail, style = MaterialTheme.typography.bodySmall)
|
// 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)
|
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-3
@@ -168,6 +168,10 @@ class DownstreamMeasurement(private val ids: IdSource) {
|
|||||||
trainCount: Int = 100,
|
trainCount: Int = 100,
|
||||||
trainSizeBytes: Int = 300,
|
trainSizeBytes: Int = 300,
|
||||||
trainIntervalUs: Int = 3_000,
|
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>> {
|
): Pair<List<Test>, List<Finding>> {
|
||||||
val tests = ArrayList<Test>()
|
val tests = ArrayList<Test>()
|
||||||
val findings = ArrayList<Finding>()
|
val findings = ArrayList<Finding>()
|
||||||
@@ -175,7 +179,8 @@ class DownstreamMeasurement(private val ids: IdSource) {
|
|||||||
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
|
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
|
||||||
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
|
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
|
||||||
val train = downTrain(
|
val train = downTrain(
|
||||||
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs,
|
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes,
|
||||||
|
trainIntervalUs, trainDscp,
|
||||||
)
|
)
|
||||||
|
|
||||||
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
|
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
|
||||||
@@ -338,15 +343,19 @@ class DownstreamMeasurement(private val ids: IdSource) {
|
|||||||
|
|
||||||
private fun downTrain(
|
private fun downTrain(
|
||||||
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
|
||||||
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int,
|
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int, dscp: Int = -1,
|
||||||
): TrainResult {
|
): TrainResult {
|
||||||
val testId = ids.uuid()
|
val testId = ids.uuid()
|
||||||
val started = ids.monoNs()
|
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 {
|
val reply = runCatching {
|
||||||
control.action(
|
control.action(
|
||||||
credential, sessionId,
|
credential, sessionId,
|
||||||
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""",
|
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,""" +
|
||||||
|
""""interval_us":$intervalUs$dscpField}""",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (reply.isFailure) {
|
if (reply.isFailure) {
|
||||||
@@ -394,6 +403,12 @@ class DownstreamMeasurement(private val ids: IdSource) {
|
|||||||
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
|
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
|
||||||
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
|
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
|
||||||
sendIntervalUs = intervalUs,
|
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
|
) as JsonObject
|
||||||
|
|
||||||
@@ -490,4 +505,6 @@ data class DownTrainMetrics(
|
|||||||
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
|
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
|
||||||
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
|
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
|
||||||
@SerialName("send_interval_us") val sendIntervalUs: Int,
|
@SerialName("send_interval_us") val sendIntervalUs: Int,
|
||||||
|
@SerialName("dscp_requested") val dscpRequested: Int? = null,
|
||||||
|
@SerialName("dscp_applied") val dscpApplied: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ class ServerMeasurement(
|
|||||||
tests.add(test)
|
tests.add(test)
|
||||||
allFindings.addAll(findings)
|
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
|
// 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
|
// 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.
|
// quietly against an older server rather than reported as a failure of the network.
|
||||||
|
|||||||
+159
@@ -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,
|
||||||
|
)
|
||||||
@@ -65,4 +65,41 @@ class LiveThroughputTest {
|
|||||||
assertTrue(m.contains(""""limited_by":"duration""""),
|
assertTrue(m.contains(""""limited_by":"duration""""),
|
||||||
"the run did not end on the clock, so the rate measures the server, not the path: $m")
|
"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,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ data class MeasurementDocument(
|
|||||||
data class Run(
|
data class Run(
|
||||||
val id: String, // UUIDv7
|
val id: String, // UUIDv7
|
||||||
val trigger: Trigger,
|
val trigger: Trigger,
|
||||||
|
val mode: RunMode = RunMode.SHORT,
|
||||||
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||||
@SerialName("ended_at") val endedAt: String? = null,
|
@SerialName("ended_at") val endedAt: String? = null,
|
||||||
val clock: Clock,
|
val clock: Clock,
|
||||||
@@ -35,9 +36,58 @@ data class Run(
|
|||||||
val device: DeviceInfo,
|
val device: DeviceInfo,
|
||||||
val tiers: Tiers,
|
val tiers: Tiers,
|
||||||
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||||
|
val constraints: Constraints = Constraints(),
|
||||||
val notes: String? = null,
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long the run watched the network — and therefore what its silence is worth.
|
||||||
|
*
|
||||||
|
* A [SHORT] run is a sequence of one-shot probes: each looks at the network for a second or two and
|
||||||
|
* moves on. That is enough to characterise a network's *configuration*, and it is structurally
|
||||||
|
* incapable of seeing anything intermittent. A wifi link that drops for four seconds every two
|
||||||
|
* minutes, a resolver that stalls under load, an AP that roams — none of these leave a trace in
|
||||||
|
* thirty seconds of probing unless the run happened to coincide with one.
|
||||||
|
*
|
||||||
|
* A [LONG] run starts continuous listeners at t=0, runs the same battery beside them, and keeps
|
||||||
|
* sampling until the window closes. It answers a different question, so a reader must not treat the
|
||||||
|
* two alike: **the mode is what licenses an argument from absence**. "No drops were observed" means
|
||||||
|
* something after five minutes of watching and nothing at all after a thirty-second run, and
|
||||||
|
* without this field the two documents are indistinguishable.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
enum class RunMode {
|
||||||
|
@SerialName("short") SHORT,
|
||||||
|
@SerialName("long") LONG,
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class Trigger {
|
enum class Trigger {
|
||||||
@SerialName("manual") MANUAL,
|
@SerialName("manual") MANUAL,
|
||||||
|
|||||||
+141
-5
@@ -103,6 +103,26 @@ object FindingRegistry {
|
|||||||
"Android's own connectivity checks fail on this network.",
|
"Android's own connectivity checks fail on this network.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The finding a short run cannot make.
|
||||||
|
*
|
||||||
|
* Every one-shot probe describes the network during its own two seconds. A link that drops and
|
||||||
|
* returns between two of them leaves no trace anywhere in the document — the probes before and
|
||||||
|
* after both succeed, and the run reports a healthy network. Only a listener that watches the
|
||||||
|
* whole window sees the gap, which is why this is emitted from `networks[].changes[]` (§4)
|
||||||
|
* rather than from any test's evidence.
|
||||||
|
*
|
||||||
|
* MEDIUM by default and escalated by the emitter on repeat: one drop in five minutes is worth
|
||||||
|
* knowing about, three is the difference between "the wifi hiccuped" and "this link is why
|
||||||
|
* calls keep dropping". Deliberately claims a *completed* cycle — lost and then regained — so
|
||||||
|
* it never fires for a network that was simply turned off partway through the run.
|
||||||
|
*/
|
||||||
|
val LINK_FLAPPING = FindingSpec(
|
||||||
|
"connectivity.link_flapping", Category.CONNECTIVITY, Severity.MEDIUM,
|
||||||
|
"A network dropped and came back one or more times during the run.",
|
||||||
|
rulesOut = "A momentary probe failure: the drop was watched happening, not inferred from silence.",
|
||||||
|
)
|
||||||
|
|
||||||
// ---- mtu -------------------------------------------------------------------------
|
// ---- mtu -------------------------------------------------------------------------
|
||||||
|
|
||||||
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
val MTU_REDUCED_DOWNSTREAM = FindingSpec(
|
||||||
@@ -169,10 +189,125 @@ object FindingRegistry {
|
|||||||
// they silently rolled up under connectivity: the third instance of a prefix disagreeing with
|
// 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.
|
// 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(
|
val V6_BROKEN = FindingSpec(
|
||||||
"v6.broken", Category.IPV6, Severity.MEDIUM,
|
"v6.broken", Category.IPV6, Severity.HIGH,
|
||||||
"IPv6 is configured on this network but does not work.",
|
"IPv6 is advertised on this network but carries no traffic.",
|
||||||
rulesOut = "Absence of IPv6: it is provisioned, it simply fails.",
|
rulesOut = "ICMP filtering as the benign explanation: a TCP connection over IPv6 failed too.",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,13 +325,14 @@ object FindingRegistry {
|
|||||||
/** Every registered finding, in declaration order. */
|
/** Every registered finding, in declaration order. */
|
||||||
val all: List<FindingSpec> = listOf(
|
val all: List<FindingSpec> = listOf(
|
||||||
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
UDP_UNREACHABLE, UDP_UNREACHABLE_UPSTREAM, UDP_LOSS, LOSS_UPSTREAM, LOSS_DOWNSTREAM,
|
||||||
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET,
|
DOWNSTREAM_BLOCKED, DOWNSTREAM_REORDER, CAPTIVE_PORTAL, NO_INTERNET, LINK_FLAPPING,
|
||||||
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
MTU_REDUCED_DOWNSTREAM, MTU_DOWNSTREAM_BLACKHOLE, FRAGMENTS_BLOCKED,
|
||||||
FRAGMENT_REORDER_SENSITIVE,
|
FRAGMENT_REORDER_SENSITIVE,
|
||||||
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
NAT_UDP_REBINDING, NAT_SYMMETRIC,
|
||||||
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
THROUGHPUT_NO_DELIVERY, THROUGHPUT_BELOW_OFFERED,
|
||||||
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
DNS_ANSWER_REWRITTEN, DNS_AUTHORITATIVE_UNREACHABLE,
|
||||||
V6_BROKEN, V6_NOT_OFFERED,
|
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 }
|
private val byCode: Map<String, FindingSpec> = all.associateBy { it.code }
|
||||||
|
|||||||
@@ -18,6 +18,39 @@ data class Network(
|
|||||||
val wifi: Wifi? = null,
|
val wifi: Wifi? = null,
|
||||||
val cellular: Cellular? = null,
|
val cellular: Cellular? = null,
|
||||||
val changes: List<NetworkChange> = emptyList(),
|
val changes: List<NetworkChange> = emptyList(),
|
||||||
|
@SerialName("system_verdict") val systemVerdict: SystemVerdict? = null,
|
||||||
|
/**
|
||||||
|
* Whether an ordinary app may send on this network at all.
|
||||||
|
*
|
||||||
|
* False for the carrier's special-purpose networks — IMS/VoLTE, MMS, XCAP — which appear
|
||||||
|
* beside the real ones in Android's list and carry neither `INTERNET` nor `NOT_RESTRICTED`.
|
||||||
|
* Binding to those needs `CONNECTIVITY_USE_RESTRICTED_NETWORKS`, a privileged permission no
|
||||||
|
* normal app can hold, so the refusal is permanent and says nothing about the network's
|
||||||
|
* health. Recorded rather than hidden: a reader seeing an interface with no measurements
|
||||||
|
* against it deserves to know the OS forbade them, instead of concluding the link is dead.
|
||||||
|
*/
|
||||||
|
@SerialName("app_usable") val appUsable: Boolean? = 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
|
@Serializable
|
||||||
@@ -111,3 +144,38 @@ data class NetworkChange(
|
|||||||
val kind: String, // lost | gained | link_changed
|
val kind: String, // lost | gained | link_changed
|
||||||
val detail: JsonObject? = null,
|
val detail: JsonObject? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a network's `changes[]` add up to.
|
||||||
|
*
|
||||||
|
* Lives beside the type rather than in the collector that produces it because two independent
|
||||||
|
* consumers ask the same question — the watcher, computing its metrics, and the run engine,
|
||||||
|
* deciding whether to emit `connectivity.link_flapping` — and a document whose metric and finding
|
||||||
|
* disagreed about how many times the link dropped would be worse than one reporting neither.
|
||||||
|
*/
|
||||||
|
object NetworkChanges {
|
||||||
|
|
||||||
|
const val LOST = "lost"
|
||||||
|
const val GAINED = "gained"
|
||||||
|
const val LINK_CHANGED = "link_changed"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completed drop-and-return cycles: a `lost` with a later `gained` on the same network.
|
||||||
|
*
|
||||||
|
* A cycle has to *complete*. A link that goes away at minute four and is still gone when the
|
||||||
|
* window closes was not flapping — it was switched off, or the device was carried out of
|
||||||
|
* range, and calling that the same fault would put a phone in a lift beside a failing access
|
||||||
|
* point.
|
||||||
|
*/
|
||||||
|
fun flapCycles(kinds: List<String>): Int {
|
||||||
|
var cycles = 0
|
||||||
|
var down = false
|
||||||
|
for (k in kinds) {
|
||||||
|
if (k == LOST) down = true
|
||||||
|
else if (k == GAINED && down) { cycles++; down = false }
|
||||||
|
}
|
||||||
|
return cycles
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flapCyclesOf(changes: List<NetworkChange>): Int = flapCycles(changes.map { it.kind })
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ data class CategorySummary(
|
|||||||
* (critical|high → red, medium|low → yellow, info/none → green).
|
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||||
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||||
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
* - 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
|
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||||
* findings or tests appear in the summary.
|
* findings or tests appear in the summary.
|
||||||
@@ -44,7 +47,10 @@ object Verdicts {
|
|||||||
private fun isInconclusiveTest(s: TestStatus) =
|
private fun isInconclusiveTest(s: TestStatus) =
|
||||||
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||||
|
|
||||||
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
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 testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||||
val findingsByCat = findings.groupBy { it.category }
|
val findingsByCat = findings.groupBy { it.category }
|
||||||
val categories = (testsByCat.keys + findingsByCat.keys)
|
val categories = (testsByCat.keys + findingsByCat.keys)
|
||||||
@@ -72,7 +78,14 @@ object Verdicts {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val overall = deriveOverall(perCat.values)
|
// 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)
|
return Summary(overall = overall, categories = perCat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ object TestType {
|
|||||||
// dns
|
// dns
|
||||||
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||||
const val DNS_CANARY = "dns.canary"
|
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_INTERCEPTION = "dns.interception"
|
||||||
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||||
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||||
@@ -126,6 +133,16 @@ object TestType {
|
|||||||
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||||
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||||
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||||
|
/**
|
||||||
|
* WS-Discovery (UDP 3702) and NetBIOS name service (UDP 137). Registry additions, v1.2.
|
||||||
|
*
|
||||||
|
* Both are passive: the traffic is broadcast to the segment whether or not anyone asks, so
|
||||||
|
* listening is the whole measurement. They earn their own ids rather than folding into
|
||||||
|
* [LOCAL_SSDP_INVENTORY] because what they imply differs — WS-Discovery inventories printers
|
||||||
|
* and cameras, while NetBIOS/LLMNR chatter is a security finding in its own right.
|
||||||
|
*/
|
||||||
|
const val LOCAL_WSD_INVENTORY = "local.wsd_inventory"
|
||||||
|
const val LOCAL_NETBIOS_INVENTORY = "local.netbios_inventory"
|
||||||
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||||
const val LOCAL_NTP = "local.ntp"
|
const val LOCAL_NTP = "local.ntp"
|
||||||
// peer
|
// peer
|
||||||
|
|||||||
@@ -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 != "::"
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the counting behind `connectivity.link_flapping`.
|
||||||
|
*
|
||||||
|
* The finding claims a link went away and came back, and its severity escalates on repetition, so
|
||||||
|
* this is arithmetic a person reading a report will act on. The cases that matter are the ones
|
||||||
|
* where the naive count is wrong: a link still down when the window closed, and a run that started
|
||||||
|
* while the link was already gone.
|
||||||
|
*/
|
||||||
|
class NetworkChangesTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aQuietWindowHasNoCycles() {
|
||||||
|
assertEquals(0, NetworkChanges.flapCycles(emptyList()))
|
||||||
|
assertEquals(0, NetworkChanges.flapCycles(listOf("link_changed", "link_changed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun oneDropAndReturnIsOneCycle() {
|
||||||
|
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained")))
|
||||||
|
assertEquals(
|
||||||
|
1,
|
||||||
|
NetworkChanges.flapCycles(listOf("link_changed", "lost", "link_changed", "gained")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun repeatedDropsCountSeparately() {
|
||||||
|
assertEquals(3, NetworkChanges.flapCycles(listOf("lost", "gained", "lost", "gained", "lost", "gained")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link that is still down when the run ends was not flapping — it was switched off, or the
|
||||||
|
// device left its range. Counting that as a cycle would put a phone in a lift beside a failing
|
||||||
|
// access point.
|
||||||
|
@Test
|
||||||
|
fun aDropThatNeverReturnsIsNotACycle() {
|
||||||
|
assertEquals(0, NetworkChanges.flapCycles(listOf("lost")))
|
||||||
|
assertEquals(1, NetworkChanges.flapCycles(listOf("lost", "gained", "lost")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mirror case: the window opened while the network was already gone, so its return is the
|
||||||
|
// first thing seen. Nothing was watched dropping, so nothing is claimed.
|
||||||
|
@Test
|
||||||
|
fun aReturnWithNoObservedDropIsNotACycle() {
|
||||||
|
assertEquals(0, NetworkChanges.flapCycles(listOf("gained")))
|
||||||
|
assertEquals(0, NetworkChanges.flapCycles(listOf("gained", "link_changed")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theChangeOverloadAgreesWithTheKindsOverload() {
|
||||||
|
val changes = listOf(
|
||||||
|
NetworkChange(atMonoNs = 1, kind = NetworkChanges.LOST),
|
||||||
|
NetworkChange(atMonoNs = 2, kind = NetworkChanges.GAINED),
|
||||||
|
NetworkChange(atMonoNs = 3, kind = NetworkChanges.LINK_CHANGED),
|
||||||
|
)
|
||||||
|
assertEquals(1, NetworkChanges.flapCyclesOf(changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,4 +20,8 @@ kotlin {
|
|||||||
}
|
}
|
||||||
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
tasks.test { useJUnitPlatform() }
|
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) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,7 +119,11 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
private fun transform(type: LogicalType?, value: String): String = when (type) {
|
||||||
null -> value
|
// 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.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
|
||||||
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
|
||||||
LogicalType.IP4 -> ip4(value)
|
LogicalType.IP4 -> ip4(value)
|
||||||
@@ -129,6 +133,38 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
|
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 -------------------------------------------------------------
|
// ---- per-type transforms -------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,7 +294,7 @@ class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
|
|||||||
|
|
||||||
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
|
||||||
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
|
||||||
cache.getOrPut("$domain | |||||||