Compare commits
@@ -40,3 +40,6 @@ keystore.properties
|
|||||||
|
|
||||||
# wrangler build/dev artifacts
|
# wrangler build/dev artifacts
|
||||||
web/.wrangler/
|
web/.wrangler/
|
||||||
|
|
||||||
|
# Eclipse/JDT output from the VSCodium Java extension — not a build artifact we own
|
||||||
|
echolot-app/*/bin/
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
|
|||||||
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
|
- Known devices: OnePlus 15 (CPH2747, A16) — Shizuku UserService works;
|
||||||
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
||||||
it. Full history in build-status.md.
|
it. Full history in build-status.md.
|
||||||
|
- **Wireless-adb beacon** (`echolot-app/adb-beacon` + `tools/adb-beacon/receiver.py`): the
|
||||||
|
wireless-debug port rotates every couple of minutes on these devices; the beacon reads the live
|
||||||
|
port from adbd's own mDNS (`_adb-tls-connect._tcp`, filtered to the device's own IP) and POSTs
|
||||||
|
it to fmr:443 (cleartext allowed; over a validated net); a PC connector polls and reconnects.
|
||||||
|
Bootstrap needs one manual adb connection to install it, then it self-heals. Existing adb
|
||||||
|
connections survive port rotation — only fresh connects need the new port.
|
||||||
|
**Resolve adbd's mDNS advertisement exactly ONCE per service instance** (re-resolving makes
|
||||||
|
adbd re-arm the connection, which spams "wireless debugging connected" notifications); the
|
||||||
|
periodic heartbeat only re-POSTs the cached port. Rotation is still caught: onServiceLost
|
||||||
|
clears the guard, so the new advertisement is resolved once and reported within seconds
|
||||||
|
(verified: 37089 -> 33667 reported 6 s after rotation).
|
||||||
|
**Known limitation — do not run the beacon on the OnePlus.** On network churn (SSID jump, roam)
|
||||||
|
adbd repeatedly drops and re-publishes its advertisement, so lost/found cycles keep clearing the
|
||||||
|
resolve-guard; each resolve makes adbd re-arm and post a "wireless debugging connected"
|
||||||
|
notification. Guarding reduces but cannot eliminate this — resolving adbd's own mDNS record is
|
||||||
|
inherently noisy on that device. Use manual `ip:port` there (ask the user), or read the port via
|
||||||
|
Shizuku (`ss -tlnp | grep adbd`, no mDNS) if the shell tier is available.
|
||||||
|
- **Shizuku start kills the adb bridge and the beacon can't auto-recover it.** Shizuku's non-root
|
||||||
|
start pairs over wireless debugging and runs its starter through adb, hijacking the channel:
|
||||||
|
adb goes device→offline→refused, and adbd keeps advertising the now-dead port over mDNS (stale),
|
||||||
|
so the beacon reports a port that no longer accepts connections. Workaround: after starting
|
||||||
|
Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh
|
||||||
|
port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
|
||||||
|
around this (or USB, if ever available).
|
||||||
- As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
|
- As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
|
||||||
AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
|
AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
|
||||||
Android Studio, its `jbr` directory works as `JAVA_HOME`.
|
Android Studio, its `jbr` directory works as `JAVA_HOME`.
|
||||||
|
|||||||
@@ -254,3 +254,292 @@ caught a finding: **Google applies 0x20 case randomization** (mixed-case qname),
|
|||||||
not — captured via `case_preserved`. Capabilities now: udp-probe, delayed-echo, connect-back,
|
not — captured via `case_preserved`. Capabilities now: udp-probe, delayed-echo, connect-back,
|
||||||
tcp-echo, stun-5780, canary-dns. Kept the hand-rolled stdlib DNS (no miekg/dns) — validated
|
tcp-echo, stun-5780, canary-dns. Kept the hand-rolled stdlib DNS (no miekg/dns) — validated
|
||||||
against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, checksum-verified).
|
against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, checksum-verified).
|
||||||
|
|
||||||
|
## Server v0.3.2 + v0.3.3 (2026-07-31)
|
||||||
|
- **v0.3.2 — control-plane security (live on fmr, externally verified):** `POST /v1/echo`
|
||||||
|
reflects the received request head+body (b64) and observed TLS (version/cipher/SNI/ALPN) —
|
||||||
|
captured real SNI `fmr-1.echo-lot.app` and an injected header over public TLS1.3; `GET
|
||||||
|
/v1/tls-reference` returns the served DER chain + pin (cross-checked against the openssl-derived
|
||||||
|
pin). Optional cleartext echo listener (default off). Capability `http-echo`.
|
||||||
|
- **v0.3.3 — MTU probe (data plane):** MTU_PROBE (0x09) → small MTU_ACK (0x0A) carrying the
|
||||||
|
received datagram size; client DF-probes increasing sizes to find path MTU / black holes. ACK
|
||||||
|
is tiny → never amplifies. Tested.
|
||||||
|
- **Note on trains:** upstream trains (TRAIN_DATA 0x03) are already observable — every HMAC-valid
|
||||||
|
packet is recorded (seq/t_rx/size/type) with no per-packet response, so loss/reordering/inter-
|
||||||
|
arrival are visible via GET observations. The dedicated data-plane TRAIN_REPORT (0x05) is
|
||||||
|
deferred: §3.4 anti-amplification means it needs an asymmetric grant + columnar multi-datagram
|
||||||
|
encoding — a focused batch, not a corner to rush.
|
||||||
|
Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain;
|
||||||
|
real admin UI.
|
||||||
|
|
||||||
|
## Server self-test + host tuning — v0.3.4/v0.3.5, fmr proven good (2026-07-31)
|
||||||
|
The daemon now proves its own host is a clean measurement target:
|
||||||
|
- **sysctl audit** (`GET /admin/selftest`, startup warnings): on first run it flagged exactly 4
|
||||||
|
real issues on fmr — accept_ra=1 on a static-v6 host, accept_redirects=1, send_redirects=1,
|
||||||
|
icmp_ratelimit=1000. Recommended `server/deploy/99-echolot-sysctl.conf` applied (v6 default
|
||||||
|
route/addrs are proto static with 0 RA-derived routes, so disabling accept_ra is safe —
|
||||||
|
verified v6 egress intact after). Now sysctl_ok=true, 0 warnings.
|
||||||
|
- **egress-MTU self-proof**: DF PMTUD via IP_MTU_DISCOVER + getsockopt IP_MTU (v0.3.4 had a bug —
|
||||||
|
read IP_MTU without connecting → ENOTCONN; v0.3.5 connects first). fmr reports 1500 on both v4
|
||||||
|
and v6 → mtu_ok=true, so client MTU tests are trustworthy.
|
||||||
|
- Both signals ride in the profile as `server_selftest{mtu_ok,sysctl_ok}` so a client can skip
|
||||||
|
MTU testing when the server can't support it honestly.
|
||||||
|
fmr profile now: `{mtu_ok: true, sysctl_ok: true}`.
|
||||||
|
|
||||||
|
## Server v0.3.6 — tls-echo / JA4: spec §4 COMPLETE (2026-07-31)
|
||||||
|
The elt-echo TLS variant runs on the TCP-echo port (8441), multiplexed by a timed 0x16 peek
|
||||||
|
(plain echo stays server-speaks-first; a TLS ClientHello routes to the TLS path). It captures
|
||||||
|
the full ClientHello, returns it raw (b64) + as a JA4 fingerprint (FoxIO), then TLS byte-echoes —
|
||||||
|
the sec.clienthello_echo evidence. Hand-rolled ClientHello parser (ciphers/exts/ALPN/
|
||||||
|
supported_versions/sig-algs, GREASE-excluded), unit-tested. **Cross-client verified on fmr**:
|
||||||
|
openssl → t13d3013eo (30 ciphers), python ssl → t13d1712eo (17) — different stacks, different
|
||||||
|
fingerprints, correct _a structure both. Capability tls-echo.
|
||||||
|
|
||||||
|
Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp-probe,
|
||||||
|
delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns.
|
||||||
|
Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT
|
||||||
|
retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI.
|
||||||
|
|
||||||
|
## Production app — echolot-app/, core-protocol proven live (2026-07-31)
|
||||||
|
Started the Android client, bottom-up from the verifiable spine. `echolot-app/` is a multi-module
|
||||||
|
Gradle build; `core-protocol` is a **pure Kotlin/JVM** module (no Android SDK) implementing the
|
||||||
|
client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via
|
||||||
|
HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin),
|
||||||
|
HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
|
||||||
|
byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key
|
||||||
|
derivation provably matches the server). **Verified END-TO-END against live fmr** via
|
||||||
|
`scripts/test-fmr.sh` (mint token over SSH → enroll on public control plane → run LiveServerTest):
|
||||||
|
profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's
|
||||||
|
observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it:
|
||||||
|
java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte
|
||||||
|
requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement
|
||||||
|
(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI.
|
||||||
|
|
||||||
|
## App: core-measurement + core-engine — full server-facing vertical proven (2026-07-31)
|
||||||
|
Two more pure-Kotlin/JVM modules, both verifiable without a device:
|
||||||
|
- **core-measurement**: the measurement-schema.md document model (two-clock, columnar trains,
|
||||||
|
test-type registry, anonymization types, finding-requires-evidence). The §7.3 deterministic
|
||||||
|
verdict derivation is implemented + unit-tested; document JSON round-trips.
|
||||||
|
- **core-engine**: the run engine composing core-protocol probes into core-measurement documents.
|
||||||
|
Injected clock/UUID source (pure, testable). Runs a server ECHO train → RTT distribution, loss,
|
||||||
|
and NAT-rebinding detection (from the server's observed source port) as train.udp_updown.
|
||||||
|
**Verified END-TO-END against fmr**: 20-packet train, 0% loss, RTT 1.7/2.5/6.9ms, single
|
||||||
|
observed port (no rebinding) → valid MeasurementDocument (2.3kB), overall GREEN.
|
||||||
|
So the whole server-facing stack — protocol client → engine → schema document → verdict — is now
|
||||||
|
proven against the live server, no device needed. Next modules (core-probe device-tier,
|
||||||
|
core-shizuku dual-path, Compose app) are Android + need on-device verification.
|
||||||
|
|
||||||
|
## App: installable APK — core-probe + Compose UI (2026-07-31)
|
||||||
|
The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP
|
||||||
|
9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the `kotlin`
|
||||||
|
extension; that was the one build gotcha). Modules added:
|
||||||
|
- **core-probe** (Android lib): Probe→core-measurement Test abstraction; NetworkInventory
|
||||||
|
(LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe
|
||||||
|
(per-network icmp.ping4/6, ported from the prober's validated per-network logic).
|
||||||
|
- **app** (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a
|
||||||
|
§7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights,
|
||||||
|
networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation
|
||||||
|
(ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive
|
||||||
|
follow-ups (app degrades gracefully without them, like the prober).
|
||||||
|
Debug APK: 9.5 MB, `echolot-app/app/build/outputs/apk/debug/app-debug.apk`. Not yet run on device
|
||||||
|
(needs the user's phone). core-shizuku (dual-path executor) deferred as additive.
|
||||||
|
|
||||||
|
## App verified on-device — both phones (2026-07-31)
|
||||||
|
The production Echolot app runs on real hardware, on BOTH devices, via the beacon-managed adb:
|
||||||
|
- OnePlus 15 (CPH2747, A16) and Lenovo TB330FU (A15): link.snapshot OK, icmp.ping4 OK
|
||||||
|
(per-network, RTT ~40ms), icmp.ping6 FAILED → finding "No IPv6 ICMP path on any active network"
|
||||||
|
→ category ipv6 yellow → **Overall YELLOW**. The verdict is driven by the real broken-LAN IPv6
|
||||||
|
(RA default route, no global prefix) we first found with the prober — the product now surfaces
|
||||||
|
it end to end (probe → schema → verdict → traffic-light UI).
|
||||||
|
Wireless-adb beacon (tools/adb-beacon) made this practical: both devices self-report their
|
||||||
|
rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live
|
||||||
|
against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast
|
||||||
|
port rotation) — all handled.
|
||||||
|
|
||||||
|
## App: core-shizuku (shell tier) built + wired (2026-07-31)
|
||||||
|
Ported the prober's validated Shizuku executor into the app as a library module:
|
||||||
|
- AIDL IUserService + UserService (runs `sh -c` as shell/root in the Shizuku-spawned process),
|
||||||
|
Shizuku provider merged into the app manifest.
|
||||||
|
- **ShizukuRunner: the build-4 dual-path executor** — bind the UserService (25s + retry) where it
|
||||||
|
works (OnePlus 7/7), fall back to the legacy `Shizuku.newProcess` reflection API where it never
|
||||||
|
binds (Lenovo). `exec_path` records which path ran.
|
||||||
|
- ShizukuProbe: runs the shell battery (ip neigh / ip -6 route / ip addr / ip monitor /
|
||||||
|
network_stack DHCP / wifi dump) and emits a shizuku-tier `link.ip_monitor` Test with the raw
|
||||||
|
per-device dumps as evidence + commands_ok/exec_path metrics. Self-degrades to UNSUPPORTED when
|
||||||
|
Shizuku isn't running.
|
||||||
|
Wired into RunViewModel (runs after app-tier probes; sets tiers.shizuku). App APK assembles clean.
|
||||||
|
On-device test deferred: at build time no device was reachable (tablet wifi/beacon dropped on the
|
||||||
|
churning LAN; phone wireless debugging disabled to stop reconnect notifications). Will verify on a
|
||||||
|
device later — expecting UserService on the OnePlus, newProcess fallback on the Lenovo, per the
|
||||||
|
prober.
|
||||||
|
|
||||||
|
## App on-device: net.captive_portal verified, Shizuku degrades correctly (2026-07-31)
|
||||||
|
Installed the app (core-shizuku + net.captive_portal) on the OnePlus 15 and ran it; report archived
|
||||||
|
at `echolot-app/reports/CPH2747-app-run1.json`. Results:
|
||||||
|
- **net.captive_portal works** — Android's NetworkMonitor logic reproduced: default + wifi both
|
||||||
|
returned HTTP **204** on the HTTPS *and* HTTP generate_204 probes → `validated`; **cellular
|
||||||
|
returned neither (-1/-1) → `no_internet`**. The per-network split immediately surfaces an
|
||||||
|
asymmetry the OS hides (wifi validated, cellular can't reach the checks at all).
|
||||||
|
- **Shizuku tier degrades correctly**: `binder_alive:false` → test UNSUPPORTED, `tiers.shizuku:false`
|
||||||
|
(Shizuku isn't running on the phone). The dual-path *executing* path still needs an on-device
|
||||||
|
test with Shizuku started (expect UserService on this OnePlus).
|
||||||
|
- 5 tests now: link.snapshot ok, icmp.ping4 ok, icmp.ping6 failed, net.captive_portal ok,
|
||||||
|
link.ip_monitor(shizuku) unsupported → overall YELLOW via the IPv6 finding.
|
||||||
|
Also fixed this session: the beacon app itself caused the "wireless debugging connected"
|
||||||
|
notification spam (it re-resolved adbd's own mDNS advertisement, making adbd re-arm each time);
|
||||||
|
now resolves once per service instance and the heartbeat re-POSTs the cached port only.
|
||||||
|
|
||||||
|
## App: dns.canary verified against the live server (2026-08-01)
|
||||||
|
Built the client half of the canary-DNS measurement and verified it on the OnePlus against the
|
||||||
|
deployed fmr zone (`echolot-app/reports/CPH2747-app-run2-dns.json`):
|
||||||
|
- All four spec-frozen reference records matched byte-for-byte through the network's own resolver
|
||||||
|
(ttl-5→192.0.2.5, ttl-60→192.0.2.60, ttl-3600→192.0.2.36, ttl-86400→192.0.2.86) → nothing on
|
||||||
|
this path rewrites DNS answers (`dns.answer_integrity` in the green case).
|
||||||
|
- The un-cacheable nonce name `1006ad16.adhoc.c.echo-lot.app` resolved to 192.0.2.21 →
|
||||||
|
`reached_authoritative: true`, proving the query actually reached the canary server rather than
|
||||||
|
being answered from a cache or an interceptor.
|
||||||
|
Findings wired: `dns.answer_rewritten` (high) when a reference mismatches, and
|
||||||
|
`dns.authoritative_unreachable` (medium) when the nonce isn't answered by the canary server.
|
||||||
|
This closes the first full client↔server measurement loop: the Kotlin app measures against the Go
|
||||||
|
server's canary zone on real hardware. 6 tests now run per measurement.
|
||||||
|
|
||||||
|
## App: Shizuku shell tier VERIFIED on-device — the app is feature-complete for v1 tiers (2026-08-01)
|
||||||
|
Ran the app on the OnePlus with Shizuku running (`echolot-app/reports/CPH2747-app-run3-shizuku.json`):
|
||||||
|
- **`tiers: {app:true, shizuku:true}`**, shizuku test **ok**, **commands_ok 7/7**,
|
||||||
|
**`exec_path: UserService`** (the OnePlus binds it — matches the prober; the newProcess fallback
|
||||||
|
stays for the Lenovo).
|
||||||
|
- Evidence is the real privileged material the production parsers need: live ARP/NDP neighbor
|
||||||
|
table, per-table IPv6 routes, `ip addr`, an actual `[NEIGH]` netlink event from `ip monitor`,
|
||||||
|
IpClient DHCP logs incl. APF capabilities, and the wifi state dump — all as shell(2000).
|
||||||
|
Both privilege tiers now work end to end in the production app on real hardware, alongside the
|
||||||
|
canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6,
|
||||||
|
net.captive_portal, dns.canary, link.ip_monitor(shizuku).
|
||||||
|
|
||||||
|
## App: nat.stun_5780 — NAT behavior discovery verified against the live server (2026-08-01)
|
||||||
|
Client-side RFC 5389/5780 STUN (hand-rolled, stdlib only) exercising the server's `stun-5780`
|
||||||
|
capability. Three binding requests from ONE socket: primary, the server's OTHER-ADDRESS
|
||||||
|
(alternate IP), and CHANGE-REQUEST(port). Verified on the OnePlus
|
||||||
|
(`echolot-app/reports/CPH2747-app-run4-stun.json`):
|
||||||
|
- local `10.13.102.124` → mapped `178.191.120.247:53259`, `behind_nat: true`
|
||||||
|
- `other_address 89.185.109.151:3479` — the server's second IP answered, so RFC 5780 works
|
||||||
|
end to end (client ↔ our own STUN implementation)
|
||||||
|
- **mapping: endpoint-independent** (same external port toward a different destination → P2P
|
||||||
|
friendly); **filtering: address/port-dependent** (no reply to CHANGE-REQUEST → unsolicited
|
||||||
|
inbound is dropped). Classic full-cone-mapping + port-restricted-filtering NAT.
|
||||||
|
Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent.
|
||||||
|
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
|
||||||
|
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
|
||||||
|
throwaway connected socket). 7 tests/run.
|
||||||
|
|
||||||
|
## App: autorun mode + IPv6 severity rework (2026-08-01)
|
||||||
|
**IPv6 is no longer treated as a defect just for being absent.** The finding now depends on
|
||||||
|
whether the network actually provisioned IPv6 (a global v6 address or a `::/0` route):
|
||||||
|
- not provisioned → `ipv6.not_offered`, severity **INFO → green**. Most networks are still
|
||||||
|
IPv4-only and that is not a fault.
|
||||||
|
- provisioned but ICMPv6 fails → `ipv6.broken`, severity **MEDIUM → yellow**. Half-configured
|
||||||
|
IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a
|
||||||
|
v6 default route with no working path, so it correctly reports `ipv6.broken`.
|
||||||
|
|
||||||
|
**Autorun mode** — one adb command runs a full measurement unattended and collects the result
|
||||||
|
without any UI tapping or adb round-trip:
|
||||||
|
```
|
||||||
|
adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
curl http://<fmr>/reports # list
|
||||||
|
curl http://<fmr>/report/<name> # fetch
|
||||||
|
```
|
||||||
|
The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py
|
||||||
|
gained `POST /report`, `GET /reports`, `GET /report/<name>`), shows the result for 3 s, then
|
||||||
|
finishes itself — leaving the device as it was found. On upload failure it stays open so the
|
||||||
|
error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app
|
||||||
|
android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog.
|
||||||
|
|
||||||
|
## App: router identification, brand icons, DEV build variant (2026-08-01)
|
||||||
|
**`link.ra_source` — who is advertising IPv6 here, and what box is it?** New app-tier probe
|
||||||
|
(registry addition). Identification chain, each step recorded as evidence so nothing is guessed:
|
||||||
|
1. RA source = next-hop of the `::/0` route per network (a `fe80::` link-local).
|
||||||
|
2. **MAC recovered from the modified-EUI-64 link-local** (strip `ff:fe`, flip the U/L bit) —
|
||||||
|
e.g. `fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`. RFC 7217/privacy addresses don't encode
|
||||||
|
a MAC and are reported as such rather than guessed.
|
||||||
|
3. Vendor via a curated OUI table (`Oui.kt` — SOHO/router vendors; unknown OUIs are printed
|
||||||
|
verbatim). Locally-administered (randomized) MACs are flagged.
|
||||||
|
4. **UPnP/SSDP M-SEARCH** → the gateway's `SERVER:` banner + device-description XML gives
|
||||||
|
manufacturer / model / friendly name. This is what usually names the exact box.
|
||||||
|
5. Reverse DNS for both gateways.
|
||||||
|
All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the
|
||||||
|
gateway can still be matched — and the MAC travels with every identity source, which is the hook
|
||||||
|
for the future LLDP / mDNS cross-matching.
|
||||||
|
UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified
|
||||||
|
vendor/model.
|
||||||
|
|
||||||
|
**Icons + DEV variant.** The branding adaptive icon is now the app icon: `icon-adaptive-*.svg`
|
||||||
|
converted to Android vector drawables (SVG transform baked in, gradient background, monochrome
|
||||||
|
layer for themed icons) plus PNG mipmaps for legacy launchers. The **debug build is a separate
|
||||||
|
app**: `applicationIdSuffix .dev`, label "Echolot DEV", and a DEV-badged icon (layer-list =
|
||||||
|
production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install
|
||||||
|
and both can be installed side by side.
|
||||||
|
NOTE for tooling: the dev package is `app.echo_lot.app.dev`, activity `app.echo_lot.app.MainActivity`.
|
||||||
|
|
||||||
|
### link.ra_source verified on-device — it named the actual router (2026-08-01)
|
||||||
|
Run archived at `echolot-app/reports/CPH2747-app-run5-router-id.json`. On the wifi network the
|
||||||
|
probe identified the RA sender completely, from an unprivileged app:
|
||||||
|
- RA source `fe80::7a9a:18ff:fe54:b8f9` → **MAC 78:9A:18:54:B8:F9 recovered via EUI-64**
|
||||||
|
(matches the Shizuku neighbor table exactly) → vendor **MikroTik** by OUI
|
||||||
|
- IPv4 gateway `10.13.102.1`, reverse DNS `router.hudelist.local`
|
||||||
|
- UPnP: `RouterOS/7.23.2 UPnP/1.0 MikroTik` → manufacturer MikroTik, model Router OS,
|
||||||
|
friendly name "MikroTik Router"
|
||||||
|
So the box advertising this LAN's broken IPv6 RA is a **MikroTik running RouterOS 7.23.2**, named
|
||||||
|
by two independent methods (OUI from the address itself + UPnP device description) that corroborate.
|
||||||
|
On cellular the carrier's RA source is an RFC 7217 privacy address and is correctly reported as
|
||||||
|
"not EUI-64" rather than guessed.
|
||||||
|
The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 gateway) — the
|
||||||
|
raw material for the planned LLDP/mDNS cross-matching by MAC.
|
||||||
|
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
|
||||||
|
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
|
||||||
|
|
||||||
|
### App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
|
||||||
|
- **Progress + ETA**: `Probe.estimatedMs` (per-probe, from measured on-device durations — the
|
||||||
|
timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s,
|
||||||
|
STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted
|
||||||
|
in the total so the bar covers the whole run.
|
||||||
|
- **Cancel**: stops an in-flight run and shows what was measured so far, assembled into a normal
|
||||||
|
document (findings + verdict over the partial set). Deliberately **does not upload** — a partial
|
||||||
|
run is for the person looking at the screen, not for the record.
|
||||||
|
- **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the
|
||||||
|
status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which
|
||||||
|
covers status bar, navigation bar and display cutout.
|
||||||
|
|
||||||
|
### Shell-tier readiness shown before a run (2026-08-01)
|
||||||
|
`ShizukuAvailability` distinguishes four states and the UI only speaks when it is actionable:
|
||||||
|
- **NOT_INSTALLED → says nothing.** Users who don't use Shizuku are never nagged.
|
||||||
|
- **INSTALLED_NOT_RUNNING → amber banner** "Shizuku is installed but not running — start it to
|
||||||
|
include shell-tier tests". This is the case worth reminding about: the user has it, but a
|
||||||
|
stopped service silently costs them the whole shell tier.
|
||||||
|
- NEEDS_PERMISSION → "running but not authorised, it will ask on first use".
|
||||||
|
- READY → green "shell-tier tests will run".
|
||||||
|
Detection is listener-based (`addBinderReceivedListenerSticky` + binder-dead), because
|
||||||
|
`pingBinder()` is only truthful once ShizukuProvider has delivered the binder — a one-shot poll at
|
||||||
|
launch would show a false "not running". Installed-vs-not needs the `<queries>` package-visibility
|
||||||
|
entry on Android 11+. Verified on-device: with shizuku_server stopped the banner appears correctly.
|
||||||
|
|
||||||
|
### Shizuku banner is actionable; progress + cancel verified on-device (2026-08-01)
|
||||||
|
Tapping the shell-tier banner now does the right thing per state: **installed-but-stopped** →
|
||||||
|
deep-links into the Shizuku app (a third-party app *cannot* start Shizuku itself; the wireless-
|
||||||
|
debugging pairing flow is privileged and lives in that app, so taking the user there in one tap is
|
||||||
|
the best available), **running-but-unauthorised** → fires the Shizuku permission request directly.
|
||||||
|
The hint line states which action the tap performs.
|
||||||
|
Verified on-device in one screenshot: progress bar at "test 4 of 8 · icmp.ping6 · ~33s left",
|
||||||
|
Cancel button beside the disabled Run button, title clear of the status bar/cutout, and the banner
|
||||||
|
having live-switched from "installed but not running" to "running but not authorised" via the
|
||||||
|
binder listener when Shizuku was started mid-session.
|
||||||
|
|
||||||
|
### Why the Shizuku banner can't start wireless debugging directly (verified, 2026-08-01)
|
||||||
|
Checked against Shizuku 13.6's own manifest (pulled the APK, `aapt2 dump xmltree`): the
|
||||||
|
wireless-debugging entry points — `moe.shizuku.manager.adb.AdbPairingTutorialActivity`,
|
||||||
|
`moe.shizuku.manager.adb.AdbPairingService`, `moe.shizuku.manager.starter.StarterActivity` —
|
||||||
|
declare **no intent filters**, so they are not exported and a third-party app cannot launch them.
|
||||||
|
`MainActivity` is the only reachable entry and answers MAIN/LAUNCHER only (no deep link), which is
|
||||||
|
why the handoff lands on the screen whose primary action is the root start.
|
||||||
|
Best available behavior, now implemented: the banner still opens Shizuku, but the hint names the
|
||||||
|
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
|
||||||
|
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
|
||||||
|
debugging must be enabled first for Shizuku's wireless start to work at all.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Echolot app
|
||||||
|
|
||||||
|
The production Android client ([spec](../docs/)). Native Kotlin + Jetpack Compose. Multi-module;
|
||||||
|
built bottom-up from a verifiable protocol spine.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Type | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| `core-protocol` | pure Kotlin/JVM | **done** — client half of `probe-protocol.md`, verified live against the server |
|
||||||
|
| `core-measurement` | pure Kotlin/JVM | planned — `measurement-schema.md` types |
|
||||||
|
| `core-probe` | Android lib | planned — app-tier probes, ported from `echolot-prober` |
|
||||||
|
| `core-shizuku` | Android lib | planned — dual-path executor (UserService + newProcess fallback) |
|
||||||
|
| `app` | Android app | planned — Compose UI |
|
||||||
|
|
||||||
|
`core-protocol` is deliberately Android-free so it builds and unit-tests on any JDK (no Android
|
||||||
|
SDK) and can run **integration tests against a live server**.
|
||||||
|
|
||||||
|
## core-protocol
|
||||||
|
|
||||||
|
Implements the control plane (SPKI-pinned enrollment/profile/sessions via `HttpsURLConnection` —
|
||||||
|
Android-API-1 compatible, hostname verification off because trust is the pin), the HKDF-SHA256
|
||||||
|
session-key schedule, and the binary ELT1 UDP data plane (HMAC gate, ECHO + observation block,
|
||||||
|
MTU probe) — byte-compatible with the Go server.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./gradlew :core-protocol:test # unit tests (crypto vectors, wire round-trip)
|
||||||
|
scripts/test-fmr.sh # live end-to-end test against the deployed server
|
||||||
|
```
|
||||||
|
|
||||||
|
`test-fmr.sh` mints an enrollment token over SSH, enrolls via the public control plane, computes
|
||||||
|
the SPKI pin from the served cert, and runs `LiveServerTest` — proving the client speaks the wire
|
||||||
|
protocol to the real server (enroll → profile → session → echo+observation → MTU → observations).
|
||||||
|
The live test self-skips when `ECHOLOT_LIVE_*` env vars are absent, so unit runs and CI stay green
|
||||||
|
offline.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dev tool (NOT the product app): reports this phone's rotating wireless-debug
|
||||||
|
// endpoint to the fmr beacon so the PC can keep `adb connect` current. Reads
|
||||||
|
// the connect port from adbd's own mDNS advertisement (_adb-tls-connect._tcp)
|
||||||
|
// via NsdManager — no root, no Shizuku.
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.adbbeacon"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.echo_lot.adbbeacon"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
// Prefilled beacon config (dev tool — secret in the APK is fine).
|
||||||
|
buildConfigField("String", "BEACON_URL", "\"http://89.185.109.150:443/beacon\"")
|
||||||
|
buildConfigField("String", "BEACON_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||||
|
}
|
||||||
|
buildTypes { release { isMinifyEnabled = false } }
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures { buildConfig = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation("androidx.activity:activity:1.9.3")
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="Echolot ADB Beacon"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@android:style/Theme.Material.Light">
|
||||||
|
|
||||||
|
<activity android:name=".MainActivity" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BeaconService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="wireless-debug-endpoint-beacon" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.adbbeacon
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.nsd.NsdManager
|
||||||
|
import android.net.nsd.NsdServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreground service that tracks this phone's wireless-debug endpoint and reports it to the fmr
|
||||||
|
* beacon. The port comes from adbd's own mDNS advertisement (`_adb-tls-connect._tcp`) via
|
||||||
|
* NsdManager — continuous discovery, so a port rotation re-fires and re-reports within seconds.
|
||||||
|
* The IP is the wlan0 private IPv4 (what the PC routes to). No root, no Shizuku.
|
||||||
|
*/
|
||||||
|
class BeaconService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private lateinit var nsd: NsdManager
|
||||||
|
private var discoveryListener: NsdManager.DiscoveryListener? = null
|
||||||
|
|
||||||
|
@Volatile private var currentPort: Int = -1
|
||||||
|
/** Service instances already resolved — prevents repeat resolves (notification spam). */
|
||||||
|
private val resolvedOnce = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
private var heartbeat: Job? = null
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
nsd = getSystemService(Context.NSD_SERVICE) as NsdManager
|
||||||
|
startForeground(1, buildNotification("Starting…"))
|
||||||
|
startDiscovery()
|
||||||
|
// Re-assert the endpoint periodically, but do NOT re-resolve mDNS each time:
|
||||||
|
// resolving adbd's own advertisement provokes it to re-arm the connection, which fires
|
||||||
|
// Android's "wireless debugging connected" notification — every cycle. Discovery runs
|
||||||
|
// once; we only re-POST the cached port (cheap, silent).
|
||||||
|
heartbeat = scope.launch {
|
||||||
|
while (true) {
|
||||||
|
delay(60_000)
|
||||||
|
report()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Status.set("watching for wireless-debug port…")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDiscovery() {
|
||||||
|
val listener = object : NsdManager.DiscoveryListener {
|
||||||
|
override fun onStartDiscoveryFailed(t: String?, code: Int) { Status.set("NSD start failed ($code)") }
|
||||||
|
override fun onStopDiscoveryFailed(t: String?, code: Int) {}
|
||||||
|
override fun onDiscoveryStarted(t: String?) {}
|
||||||
|
override fun onDiscoveryStopped(t: String?) {}
|
||||||
|
override fun onServiceLost(s: NsdServiceInfo?) {
|
||||||
|
// adbd stops advertising when Wireless debugging is turned off.
|
||||||
|
currentPort = -1
|
||||||
|
s?.serviceName?.let { resolvedOnce.remove(it) } // allow one re-resolve when it returns
|
||||||
|
val warn = "⚠ Wireless debugging appears OFF (adb mDNS service gone) — re-enable it"
|
||||||
|
Status.set(warn)
|
||||||
|
updateNotification(warn)
|
||||||
|
}
|
||||||
|
override fun onServiceFound(s: NsdServiceInfo?) {
|
||||||
|
if (s == null) return
|
||||||
|
// Resolve ONCE per discovered service instance. Repeatedly resolving adbd's own
|
||||||
|
// advertisement makes it re-arm the connection and spam the user with
|
||||||
|
// "wireless debugging connected" notifications.
|
||||||
|
val key = s.serviceName ?: return
|
||||||
|
if (!resolvedOnce.add(key)) return
|
||||||
|
resolve(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
discoveryListener = listener
|
||||||
|
runCatching {
|
||||||
|
nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
|
||||||
|
}.onFailure { Status.set("NSD unavailable: ${it.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun resolve(info: NsdServiceInfo) {
|
||||||
|
nsd.resolveService(info, object : NsdManager.ResolveListener {
|
||||||
|
override fun onResolveFailed(s: NsdServiceInfo?, code: Int) {}
|
||||||
|
override fun onServiceResolved(s: NsdServiceInfo?) {
|
||||||
|
s ?: return
|
||||||
|
// On a shared LAN, NsdManager discovers EVERY device's adb
|
||||||
|
// advertisement — accept only the one whose host is THIS device's
|
||||||
|
// own IP, else we'd report a neighbour's port for our IP.
|
||||||
|
val host = s.host?.hostAddress
|
||||||
|
val mine = wifiIpv4()
|
||||||
|
if (host != null && mine != null && host != mine) return
|
||||||
|
currentPort = s.port
|
||||||
|
scope.launch { report() }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun report() {
|
||||||
|
val ip = wifiIpv4() ?: run { Status.set("no wlan0 IPv4 (is wifi up?)"); return }
|
||||||
|
val port = currentPort
|
||||||
|
if (port <= 0) {
|
||||||
|
// No adb advertisement: either discovery hasn't landed yet, or (usually) Wireless
|
||||||
|
// debugging is off. Say so plainly.
|
||||||
|
val warn = "⚠ $ip — no wireless-debug port. Is Wireless debugging ON?"
|
||||||
|
Status.set(warn); updateNotification(warn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val device = android.os.Build.MODEL.replace(Regex("[^A-Za-z0-9_.-]"), "_")
|
||||||
|
val body = """{"device":"$device","ip":"$ip","port":$port}"""
|
||||||
|
// Send over a VALIDATED internet network — the wireless-debug wifi is often a restricted
|
||||||
|
// LAN with no real internet TCP egress, so bind the report to cellular/whatever actually
|
||||||
|
// reaches the beacon.
|
||||||
|
val net = internetNetwork()
|
||||||
|
val ok = runCatching {
|
||||||
|
val url = URL(BuildConfig.BEACON_URL)
|
||||||
|
val conn = (net?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
|
||||||
|
conn.run {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 5000; readTimeout = 5000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
setRequestProperty("X-Beacon-Secret", BuildConfig.BEACON_SECRET)
|
||||||
|
outputStream.use { it.write(body.toByteArray()) }
|
||||||
|
responseCode == 200
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
|
val via = if (net != null) "via ${netLabel(net)}" else "via default net"
|
||||||
|
val line = if (ok) {
|
||||||
|
"reported [$device] $ip:$port ✓ $via\n→ ${BuildConfig.BEACON_URL}"
|
||||||
|
} else {
|
||||||
|
"report FAILED for [$device] $ip:$port $via\n→ POST ${BuildConfig.BEACON_URL}"
|
||||||
|
}
|
||||||
|
Status.set(line)
|
||||||
|
updateNotification(if (ok) "reported $ip:$port ✓" else "report failed for $ip:$port")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A network with validated internet access, preferring cellular (the wireless-debug wifi is
|
||||||
|
* frequently a restricted LAN that can't reach the beacon over TCP). */
|
||||||
|
private fun internetNetwork(): android.net.Network? {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
var fallback: android.net.Network? = null
|
||||||
|
for (n in cm.allNetworks) {
|
||||||
|
val c = cm.getNetworkCapabilities(n) ?: continue
|
||||||
|
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET)) continue
|
||||||
|
if (!c.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)) continue
|
||||||
|
if (c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR)) return n
|
||||||
|
fallback = n
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun netLabel(n: android.net.Network): String {
|
||||||
|
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||||
|
val c = cm.getNetworkCapabilities(n) ?: return "net"
|
||||||
|
return when {
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular"
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_WIFI) -> "wifi"
|
||||||
|
c.hasTransport(android.net.NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet"
|
||||||
|
else -> "net"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The wlan0 (or first private) IPv4 the PC routes to. */
|
||||||
|
private fun wifiIpv4(): String? {
|
||||||
|
return runCatching {
|
||||||
|
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||||
|
.filter { it.isUp && !it.isLoopback }
|
||||||
|
.sortedByDescending { it.name.startsWith("wlan") } // prefer wlan0
|
||||||
|
.flatMap { it.inetAddresses.asSequence() }
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.firstOrNull { it.isSiteLocalAddress }
|
||||||
|
?.hostAddress
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(text: String): Notification {
|
||||||
|
val channelId = "beacon"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val nm = getSystemService(NotificationManager::class.java)
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(channelId, "ADB Beacon", NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, channelId)
|
||||||
|
.setContentTitle("Echolot ADB Beacon")
|
||||||
|
.setContentText(text)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(text: String) {
|
||||||
|
getSystemService(NotificationManager::class.java).notify(1, buildNotification(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
|
||||||
|
heartbeat?.cancel()
|
||||||
|
scope.cancel()
|
||||||
|
Status.set("stopped")
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tiny shared status the activity polls (keeps the app dependency-free of observers). */
|
||||||
|
object Status {
|
||||||
|
@Volatile var line: String = "idle"; private set
|
||||||
|
fun set(s: String) { line = s }
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.adbbeacon
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal control surface for the beacon: Start/Stop the foreground service and show its live
|
||||||
|
* status. Deliberately plain (no Compose) — it's a dev tool.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private lateinit var status: TextView
|
||||||
|
private val ui = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding(48, 64, 48, 48)
|
||||||
|
}
|
||||||
|
val title = TextView(this).apply { text = "Echolot ADB Beacon"; textSize = 22f }
|
||||||
|
val subtitle = TextView(this).apply {
|
||||||
|
text = "Reports this phone's wireless-debug endpoint to fmr so the PC can keep adb connected.\n\n" +
|
||||||
|
"Enable Wireless debugging, then Start."
|
||||||
|
textSize = 13f; setPadding(0, 16, 0, 32)
|
||||||
|
}
|
||||||
|
status = TextView(this).apply { text = Status.line; textSize = 14f; gravity = Gravity.START }
|
||||||
|
|
||||||
|
val start = Button(this).apply {
|
||||||
|
text = "Start beacon"
|
||||||
|
setOnClickListener {
|
||||||
|
val i = Intent(this@MainActivity, BeaconService::class.java)
|
||||||
|
ContextCompat.startForegroundService(this@MainActivity, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stop = Button(this).apply {
|
||||||
|
text = "Stop beacon"
|
||||||
|
setOnClickListener { stopService(Intent(this@MainActivity, BeaconService::class.java)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addView(title); root.addView(subtitle)
|
||||||
|
root.addView(start); root.addView(stop)
|
||||||
|
root.addView(TextView(this).apply { text = "\nStatus:"; setPadding(0, 32, 0, 8) })
|
||||||
|
root.addView(status)
|
||||||
|
setContentView(root)
|
||||||
|
|
||||||
|
poll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun poll() {
|
||||||
|
status.text = Status.line
|
||||||
|
ui.postDelayed({ poll() }, 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin — no kotlin.android here (see core-probe note).
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.app"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "app.echo_lot.app"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
|
||||||
|
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
|
||||||
|
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release { isMinifyEnabled = false }
|
||||||
|
debug {
|
||||||
|
// The dev build is a separate app: own package (installs alongside a real
|
||||||
|
// Echolot), own label and a DEV-badged icon (src/debug/res).
|
||||||
|
applicationIdSuffix = ".dev"
|
||||||
|
versionNameSuffix = "-dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(project(":core-protocol"))
|
||||||
|
implementation(project(":core-engine"))
|
||||||
|
implementation(project(":core-probe"))
|
||||||
|
implementation(project(":core-shizuku"))
|
||||||
|
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 948 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Debug foreground: the production mark with a DEV ribbon so the dev build is
|
||||||
|
unmistakable on the launcher next to a real install. -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<item android:drawable="@drawable/ic_dev_badge"/>
|
||||||
|
</layer-list>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Echolot DEV</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.Echolot">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val permissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
requestRuntimePermissions()
|
||||||
|
setContent {
|
||||||
|
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||||
|
Surface(color = MaterialTheme.colorScheme.background) {
|
||||||
|
val vm: RunViewModel = viewModel()
|
||||||
|
// Automation entry point:
|
||||||
|
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
|
||||||
|
// starts a run immediately and uploads the report, so an unattended
|
||||||
|
// measurement needs no UI tapping and no adb round-trip to collect.
|
||||||
|
val autorun = intent?.getBooleanExtra("autorun", false) == true
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun) {
|
||||||
|
if (autorun) vm.run(upload = true)
|
||||||
|
}
|
||||||
|
// In autorun the app is a batch job: once the run is done AND the upload
|
||||||
|
// succeeded, show the result briefly, then close so the device is left as it
|
||||||
|
// was found. On failure it stays open so the error is visible.
|
||||||
|
val st = vm.state
|
||||||
|
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
|
||||||
|
if (autorun && !st.running && st.document != null &&
|
||||||
|
st.uploadStatus?.startsWith("uploaded") == true
|
||||||
|
) {
|
||||||
|
kotlinx.coroutines.delay(3000)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EcholotScreen(
|
||||||
|
state = vm.state,
|
||||||
|
onRun = { vm.run() },
|
||||||
|
onCancel = vm::cancel,
|
||||||
|
onDeveloperOptions = {
|
||||||
|
runCatching {
|
||||||
|
startActivity(
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.developerOptionsIntent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShizukuAction = {
|
||||||
|
// Shizuku can only be started from its own app (the pairing flow lives
|
||||||
|
// there), so send the user straight to it; if it is already running we
|
||||||
|
// just need permission.
|
||||||
|
when (vm.state.shizukuState) {
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.NEEDS_PERMISSION ->
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.requestPermission()
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING ->
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.launchIntent(this)
|
||||||
|
?.let { startActivity(it) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestRuntimePermissions() {
|
||||||
|
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
val missing = perms.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verdictColor(v: Verdict): Color = when (v) {
|
||||||
|
Verdict.GREEN -> Color(0xFF2E7D32)
|
||||||
|
Verdict.YELLOW -> Color(0xFFF9A825)
|
||||||
|
Verdict.RED -> Color(0xFFC62828)
|
||||||
|
Verdict.INCONCLUSIVE -> Color(0xFF616161)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColor(s: TestStatus): Color = when (s) {
|
||||||
|
TestStatus.OK -> Color(0xFF66BB6A)
|
||||||
|
TestStatus.PARTIAL -> Color(0xFFFFB300)
|
||||||
|
TestStatus.FAILED -> Color(0xFFEF5350)
|
||||||
|
TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EcholotScreen(
|
||||||
|
state: UiState,
|
||||||
|
onRun: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onShizukuAction: () -> Unit,
|
||||||
|
onDeveloperOptions: () -> Unit,
|
||||||
|
onExport: (MeasurementDocument) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
// Android 15 draws edge-to-edge by default: without this the title runs under the
|
||||||
|
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
|
||||||
|
// AND the display cutout, so text never lands where it can't be read.
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
|
||||||
|
|
||||||
|
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
|
||||||
|
// only users who actually use it get reminded that it must be running.
|
||||||
|
state.shizukuNotice?.let { notice ->
|
||||||
|
Card(
|
||||||
|
Modifier.fillMaxWidth().let { m ->
|
||||||
|
if (state.shizukuHint != null) m.clickable { onShizukuAction() } else m
|
||||||
|
},
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (state.shizukuReady) Color(0xFF14301F) else Color(0xFF3A2E12),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(10.dp)) {
|
||||||
|
Text(
|
||||||
|
notice, fontSize = 12.sp,
|
||||||
|
color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A),
|
||||||
|
)
|
||||||
|
state.shizukuHint?.let {
|
||||||
|
Text(it, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color(0xFFFFB454))
|
||||||
|
}
|
||||||
|
// Wireless debugging must be ON before Shizuku's wireless start works, and
|
||||||
|
// that screen (unlike Shizuku's pairing activity) is publicly launchable.
|
||||||
|
if (state.shizukuState ==
|
||||||
|
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Wireless debugging settings →",
|
||||||
|
Modifier.padding(top = 6.dp).clickable { onDeveloperOptions() },
|
||||||
|
fontSize = 11.sp, color = Color(0xFF35E0C4),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Button(onClick = onRun, enabled = !state.running) {
|
||||||
|
Text(if (state.running) "Running…" else "Run measurement")
|
||||||
|
}
|
||||||
|
if (state.running) {
|
||||||
|
OutlinedButton(onClick = onCancel) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
state.document?.let { doc ->
|
||||||
|
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.uploadStatus?.let {
|
||||||
|
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.running) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val frac = if (state.stepsTotal > 0)
|
||||||
|
state.stepsDone.toFloat() / state.stepsTotal else 0f
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { frac },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
if (state.stepsTotal > 0)
|
||||||
|
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(state.currentStep ?: "…", fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
|
||||||
|
if (state.etaSeconds > 0) {
|
||||||
|
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.document?.let { doc -> Results(doc) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Results(doc: MeasurementDocument) {
|
||||||
|
val summary = doc.summary
|
||||||
|
if (summary != null) {
|
||||||
|
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(16.dp)) {
|
||||||
|
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowCategories(summary.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
RouterPanel(doc)
|
||||||
|
|
||||||
|
SectionTitle("Networks (${doc.networks.size})")
|
||||||
|
for (n in doc.networks) {
|
||||||
|
Text("• ${n.transport.name.lowercase()} ${n.iface ?: ""} — " +
|
||||||
|
n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" },
|
||||||
|
fontSize = 13.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
|
||||||
|
SectionTitle("Tests (${doc.tests.size})")
|
||||||
|
for (t in doc.tests) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Dot(statusColor(t.status))
|
||||||
|
Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
|
||||||
|
Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000
|
||||||
|
Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.findings.isNotEmpty()) {
|
||||||
|
SectionTitle("Findings (${doc.findings.size})")
|
||||||
|
for (f in doc.findings) {
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(f.title, fontWeight = FontWeight.Medium)
|
||||||
|
Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(f.description, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who advertises IPv6 here, and what box is it? Pulled from the link.ra_source evidence and shown
|
||||||
|
* up front — a rogue/misconfigured RA sender is a top cause of broken IPv6, and "some router" is
|
||||||
|
* not actionable without an identity.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RouterPanel(doc: MeasurementDocument) {
|
||||||
|
val test = doc.tests.firstOrNull { it.type == TestType.LINK_RA_SOURCE } ?: return
|
||||||
|
val nets = (test.evidence?.get("networks") as? kotlinx.serialization.json.JsonArray) ?: return
|
||||||
|
if (nets.isEmpty()) return
|
||||||
|
|
||||||
|
SectionTitle("Router / IPv6 advertiser")
|
||||||
|
for (el in nets) {
|
||||||
|
val o = el as? kotlinx.serialization.json.JsonObject ?: continue
|
||||||
|
fun f(k: String): String? =
|
||||||
|
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
|
||||||
|
val ra = f("ra_source")
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(f("network") ?: "network", fontWeight = FontWeight.Medium)
|
||||||
|
// The identity line: vendor/model if we could pin it down.
|
||||||
|
val identity = listOfNotNull(
|
||||||
|
f("upnp_manufacturer"), f("upnp_model"), f("ra_source_vendor"),
|
||||||
|
).distinct().joinToString(" · ").ifBlank { null }
|
||||||
|
identity?.let {
|
||||||
|
Text(it, fontWeight = FontWeight.SemiBold, color = Color(0xFF35E0C4), fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
f("upnp_friendly_name")?.let { Row0("name", it) }
|
||||||
|
ra?.let { Row0("RA source", it) }
|
||||||
|
f("ra_source_mac")?.let { Row0("RA source MAC", it) }
|
||||||
|
f("ra_source_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("reverse DNS", it) }
|
||||||
|
f("v4_gateway")?.let { Row0("IPv4 gateway", it) }
|
||||||
|
f("v4_gateway_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("gateway rDNS", it) }
|
||||||
|
f("upnp_server")?.let { Row0("UPnP server", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Row0(label: String, value: String) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("$label:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FlowCategories(categories: Map<String, CategorySummary>) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
for ((name, cat) in categories) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Dot(verdictColor(cat.verdict))
|
||||||
|
Text(name, modifier = Modifier.weight(1f))
|
||||||
|
Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "",
|
||||||
|
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dot(color: Color) {
|
||||||
|
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) {
|
||||||
|
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */
|
||||||
|
object Report {
|
||||||
|
private val json = Json { prettyPrint = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
fun toJson(doc: MeasurementDocument): String =
|
||||||
|
json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
|
||||||
|
fun share(ctx: Context, doc: MeasurementDocument): Intent {
|
||||||
|
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
|
||||||
|
val file = File(dir, "echolot-run-${doc.run.id}.json")
|
||||||
|
file.writeText(toJson(doc))
|
||||||
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
|
||||||
|
return Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "application/json"
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.MeasurementDocument
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
|
||||||
|
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
|
||||||
|
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
|
||||||
|
*/
|
||||||
|
object ReportUploader {
|
||||||
|
|
||||||
|
data class Result(val ok: Boolean, val detail: String)
|
||||||
|
|
||||||
|
fun upload(doc: MeasurementDocument): Result {
|
||||||
|
val url = BuildConfig.REPORT_UPLOAD_URL
|
||||||
|
if (url.isBlank()) return Result(false, "no upload URL configured")
|
||||||
|
return try {
|
||||||
|
val body = Report.toJson(doc).toByteArray()
|
||||||
|
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||||
|
requestMethod = "POST"
|
||||||
|
connectTimeout = 10_000
|
||||||
|
readTimeout = 15_000
|
||||||
|
doOutput = true
|
||||||
|
setRequestProperty("Content-Type", "application/json")
|
||||||
|
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
|
||||||
|
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
|
||||||
|
?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
conn.disconnect()
|
||||||
|
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Result(false, t.message ?: t.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.app
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.probe.CaptivePortalProbe
|
||||||
|
import app.echo_lot.probe.DnsCanaryProbe
|
||||||
|
import app.echo_lot.probe.IcmpProbe
|
||||||
|
import app.echo_lot.probe.LinkSnapshotProbe
|
||||||
|
import app.echo_lot.probe.StunProbe
|
||||||
|
import app.echo_lot.probe.NetworkInventory
|
||||||
|
import app.echo_lot.probe.Probe
|
||||||
|
import app.echo_lot.probe.ProbeIds
|
||||||
|
import app.echo_lot.probe.RouterIdentityProbe
|
||||||
|
import app.echo_lot.shizuku.ShizukuAvailability
|
||||||
|
import app.echo_lot.shizuku.ShizukuProbe
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class UiState(
|
||||||
|
val running: Boolean = false,
|
||||||
|
val currentStep: String? = null,
|
||||||
|
val document: MeasurementDocument? = null,
|
||||||
|
/** Result line of an automated upload (autorun mode); null when not attempted. */
|
||||||
|
val uploadStatus: String? = null,
|
||||||
|
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
|
||||||
|
val stepsDone: Int = 0,
|
||||||
|
val stepsTotal: Int = 0,
|
||||||
|
val etaSeconds: Int = 0,
|
||||||
|
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
|
||||||
|
val shizukuNotice: String? = null,
|
||||||
|
val shizukuReady: Boolean = false,
|
||||||
|
val shizukuHint: String? = null,
|
||||||
|
val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;
|
||||||
|
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
|
||||||
|
* survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a
|
||||||
|
* follow-up once enrollment UI lands.
|
||||||
|
*/
|
||||||
|
class RunViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
var state by mutableStateOf(UiState())
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var runJob: kotlinx.coroutines.Job? = null
|
||||||
|
private val stopShizukuObserver: () -> Unit
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Report shell-tier readiness up front. The binder arrives asynchronously, so this is a
|
||||||
|
// listener, not a one-shot poll — otherwise a running Shizuku would look "not running"
|
||||||
|
// for the first moment after launch.
|
||||||
|
stopShizukuObserver = ShizukuAvailability.observe(app) { st ->
|
||||||
|
state = state.copy(
|
||||||
|
shizukuNotice = ShizukuAvailability.describe(st),
|
||||||
|
shizukuReady = st == ShizukuAvailability.State.READY,
|
||||||
|
shizukuHint = ShizukuAvailability.actionHint(st),
|
||||||
|
shizukuState = st,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
stopShizukuObserver()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
// Results collected so far. A cancelled run must still be able to show what it measured.
|
||||||
|
private val collected = mutableListOf<Test>()
|
||||||
|
private var runIds: RunIds = RunIds()
|
||||||
|
private var runStartWall: String = ""
|
||||||
|
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
|
||||||
|
private var runShizukuOk = false
|
||||||
|
|
||||||
|
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
|
||||||
|
private class RunIds : ProbeIds {
|
||||||
|
val originNanos = System.nanoTime()
|
||||||
|
override fun uuid(): String = UUID.randomUUID().toString()
|
||||||
|
override fun monoNs(): Long = System.nanoTime() - originNanos
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the
|
||||||
|
* collection endpoint so an unattended run can be retrieved without adb.
|
||||||
|
*/
|
||||||
|
fun run(upload: Boolean = false) {
|
||||||
|
if (state.running) return
|
||||||
|
collected.clear()
|
||||||
|
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
|
||||||
|
runJob = viewModelScope.launch {
|
||||||
|
val doc = withContext(Dispatchers.IO) { measure() }
|
||||||
|
var status: String? = null
|
||||||
|
if (upload) {
|
||||||
|
state = state.copy(currentStep = "uploading report")
|
||||||
|
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
|
||||||
|
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
|
||||||
|
}
|
||||||
|
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
|
||||||
|
* a partial run is for the person looking at the screen, not for the record.
|
||||||
|
*/
|
||||||
|
fun cancel() {
|
||||||
|
if (!state.running) return
|
||||||
|
runJob?.cancel()
|
||||||
|
val doc = buildDocument(collected.toList())
|
||||||
|
state = UiState(
|
||||||
|
running = false, currentStep = null, document = doc,
|
||||||
|
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun measure(): MeasurementDocument {
|
||||||
|
val ctx = getApplication<Application>()
|
||||||
|
val ids = RunIds().also { runIds = it }
|
||||||
|
val startWall = Instant.now().toString().also { runStartWall = it }
|
||||||
|
|
||||||
|
step("reading networks")
|
||||||
|
val entries = NetworkInventory.snapshot(ctx)
|
||||||
|
val networks = entries.map { it.model }.also { runNetworks = it }
|
||||||
|
|
||||||
|
val probes: List<Probe> = listOf(
|
||||||
|
LinkSnapshotProbe(entries),
|
||||||
|
RouterIdentityProbe(entries),
|
||||||
|
IcmpProbe(entries, v6 = false),
|
||||||
|
IcmpProbe(entries, v6 = true),
|
||||||
|
CaptivePortalProbe(entries),
|
||||||
|
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
|
||||||
|
// the reference deployment until profiles/enrollment land in the UI.
|
||||||
|
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
|
||||||
|
StunProbe(serverHost = "fmr-1.echo-lot.app"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
|
||||||
|
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
|
||||||
|
val shizukuEstimateMs = 8_000L
|
||||||
|
val totalSteps = probes.size + 1
|
||||||
|
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
|
||||||
|
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
|
||||||
|
etaSeconds = ((remainingMs + 999) / 1000).toInt())
|
||||||
|
|
||||||
|
val tests = ArrayList<Test>()
|
||||||
|
for ((i, p) in probes.withIndex()) {
|
||||||
|
step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
|
||||||
|
val result = (
|
||||||
|
try {
|
||||||
|
p.run(ctx, ids)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Test(
|
||||||
|
id = ids.uuid(), type = p.type, tier = p.tier,
|
||||||
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED,
|
||||||
|
error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tests.add(result); collected.add(result)
|
||||||
|
remainingMs -= p.estimatedMs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
|
||||||
|
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
|
||||||
|
val shizukuTest = try {
|
||||||
|
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Test(
|
||||||
|
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
|
||||||
|
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
|
||||||
|
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
tests.add(shizukuTest); collected.add(shizukuTest)
|
||||||
|
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
|
||||||
|
|
||||||
|
return buildDocument(tests)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
|
||||||
|
private fun buildDocument(tests: List<Test>): MeasurementDocument {
|
||||||
|
val findings = deriveFindings(tests, runNetworks)
|
||||||
|
return MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
|
||||||
|
endedAt = Instant.now().toString(),
|
||||||
|
clock = Clock(monoOriginWall = runStartWall),
|
||||||
|
app = AppInfo(
|
||||||
|
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
|
||||||
|
),
|
||||||
|
device = DeviceInfo(
|
||||||
|
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
|
||||||
|
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
|
||||||
|
),
|
||||||
|
tiers = Tiers(app = true, shizuku = runShizukuOk),
|
||||||
|
),
|
||||||
|
networks = runNetworks,
|
||||||
|
tests = tests,
|
||||||
|
findings = findings,
|
||||||
|
summary = Verdicts.derive(tests, findings),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
|
||||||
|
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
|
||||||
|
*/
|
||||||
|
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
|
||||||
|
networks.any { n ->
|
||||||
|
n.link.addresses.any { a ->
|
||||||
|
a.addr.contains(':') &&
|
||||||
|
!a.addr.startsWith("fe80", ignoreCase = true) &&
|
||||||
|
!a.addr.startsWith("::1")
|
||||||
|
} || n.link.routes.any { it.dst == "::/0" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
|
||||||
|
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
|
||||||
|
val out = ArrayList<Finding>()
|
||||||
|
val ids = RunIds()
|
||||||
|
for (t in tests) {
|
||||||
|
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
when {
|
||||||
|
ev.contains("\"captive_portal\"") -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "Captive portal intercepting connections",
|
||||||
|
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
t.status == TestStatus.FAILED -> out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY,
|
||||||
|
severity = Severity.HIGH, confidence = Confidence.HIGH,
|
||||||
|
title = "No working internet on any network",
|
||||||
|
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.DNS_CANARY) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
if (ev.contains("MISMATCH")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS,
|
||||||
|
severity = Severity.HIGH, confidence = Confidence.HIGH,
|
||||||
|
title = "DNS answers are being rewritten",
|
||||||
|
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else if (ev.contains("\"reached_authoritative\":false")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM,
|
||||||
|
title = "Canary queries don't reach the authoritative server",
|
||||||
|
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.NAT_STUN_5780 && t.status == TestStatus.OK) {
|
||||||
|
val ev = t.evidence?.toString() ?: ""
|
||||||
|
if (ev.contains("address/port-dependent (symmetric NAT")) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "Symmetric NAT — peer-to-peer connections need a relay",
|
||||||
|
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
|
||||||
|
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
|
||||||
|
// and that is not a defect. What IS a defect is IPv6 that the network claims to
|
||||||
|
// provide (a global address or a default route from RA/DHCPv6) but that does not
|
||||||
|
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
|
||||||
|
// depends on whether v6 was provisioned at all.
|
||||||
|
if (ipv6Provisioned(networks)) {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
|
||||||
|
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
|
||||||
|
title = "IPv6 is configured but not working",
|
||||||
|
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
out.add(
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
|
||||||
|
severity = Severity.INFO, confidence = Confidence.HIGH,
|
||||||
|
title = "IPv4-only network (no IPv6 offered)",
|
||||||
|
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
|
||||||
|
evidenceRefs = listOf(EvidenceRef(t.id)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
|
||||||
|
state = state.copy(
|
||||||
|
currentStep = s, stepsDone = done, stepsTotal = total,
|
||||||
|
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Adaptive-icon background: the brand "tile" gradient (assets/branding). -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:pathData="M0,0h108v108h-108z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient android:startX="54" android:startY="0" android:endX="54" android:endY="108"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FF0E2433"/>
|
||||||
|
<item android:offset="1" android:color="#FF071522"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
Adaptive-icon foreground: the Focus mark (assets/branding/icon-adaptive-foreground.svg),
|
||||||
|
SVG transform baked in so the art sits inside the 66dp safe circle. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp" android:height="108dp"
|
||||||
|
android:viewportWidth="108" android:viewportHeight="108">
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
|
||||||
|
<path android:strokeColor="#FFB454" android:strokeAlpha="0.3" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M47.6,54.0 a6.4,6.4 0 1,0 12.8,0 a6.4,6.4 0 1,0 -12.8,0"/>
|
||||||
|
<path android:fillColor="#FFFFB454" android:pathData="M50.4,54.0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M42.0,48.4 V42.0 H48.4"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M59.6,42.0 H66.0 V48.4"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M66.0,59.6 V66.0 H59.6"/>
|
||||||
|
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M48.4,66.0 H42.0 V59.6"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Echolot</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Echolot" parent="android:Theme.Material.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<paths>
|
||||||
|
<cache-path name="reports" path="reports/" />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The measurement run engine: composes core-protocol probes into
|
||||||
|
// core-measurement documents. Pure Kotlin/JVM, so it is unit-testable and can
|
||||||
|
// run a full server-facing measurement against a live server.
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-protocol"))
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET")
|
||||||
|
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package engine composes core-protocol probes into core-measurement documents — the run engine
|
||||||
|
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
|
||||||
|
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
|
||||||
|
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
|
||||||
|
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
|
||||||
|
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
|
||||||
|
* (no clocks/UUIDs of its own) and unit-testable.
|
||||||
|
*/
|
||||||
|
class ServerMeasurement(
|
||||||
|
private val ids: IdSource,
|
||||||
|
private val app: AppInfo,
|
||||||
|
private val device: DeviceInfo,
|
||||||
|
) {
|
||||||
|
private val json = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
data class Config(
|
||||||
|
val controlUrl: String,
|
||||||
|
val pins: Set<String>,
|
||||||
|
val credential: String,
|
||||||
|
val target: String,
|
||||||
|
val udpHost: String,
|
||||||
|
val udpPort: Int,
|
||||||
|
val echoCount: Int = 20,
|
||||||
|
val echoPaddingBytes: Int = 64,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun run(cfg: Config): MeasurementDocument {
|
||||||
|
val runId = ids.uuid()
|
||||||
|
val startWall = ids.nowWall()
|
||||||
|
val startMono = ids.monoNs()
|
||||||
|
|
||||||
|
val control = ControlClient(cfg.controlUrl, cfg.pins)
|
||||||
|
val profile = control.profile(cfg.credential)
|
||||||
|
val session = control.createSession(cfg.credential, cfg.target)
|
||||||
|
|
||||||
|
val serverSession = ServerSession(
|
||||||
|
id = "sess-1",
|
||||||
|
profileName = profile.name,
|
||||||
|
controlUrl = cfg.controlUrl,
|
||||||
|
serverVersion = profile.serverVersion,
|
||||||
|
capabilities = profile.capabilities,
|
||||||
|
sessionId = session.sessionId,
|
||||||
|
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
|
||||||
|
)
|
||||||
|
|
||||||
|
val (test, findings) = echoTrain(cfg, control, session, startMono)
|
||||||
|
|
||||||
|
control.deleteSession(cfg.credential, session.sessionId)
|
||||||
|
|
||||||
|
val summary = Verdicts.derive(listOf(test), findings)
|
||||||
|
return MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
|
||||||
|
clock = Clock(monoOriginWall = startWall),
|
||||||
|
app = app, device = device,
|
||||||
|
tiers = Tiers(app = true),
|
||||||
|
),
|
||||||
|
serverSessions = listOf(serverSession),
|
||||||
|
tests = listOf(test),
|
||||||
|
findings = findings,
|
||||||
|
summary = summary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun echoTrain(
|
||||||
|
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
|
||||||
|
): Pair<Test, List<Finding>> {
|
||||||
|
val testId = ids.uuid()
|
||||||
|
val seqs = ArrayList<Int>()
|
||||||
|
val tTx = ArrayList<Long?>()
|
||||||
|
val tRx = ArrayList<Long?>()
|
||||||
|
val sizes = ArrayList<Int>()
|
||||||
|
val rtts = ArrayList<Double>()
|
||||||
|
val observedPorts = LinkedHashSet<Int>()
|
||||||
|
|
||||||
|
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
|
||||||
|
for (i in 0 until cfg.echoCount) {
|
||||||
|
val txMono = ids.monoNs() - startMono
|
||||||
|
val r = ps.echo(cfg.echoPaddingBytes)
|
||||||
|
seqs.add(i)
|
||||||
|
tTx.add(txMono)
|
||||||
|
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
|
||||||
|
if (r != null) {
|
||||||
|
tRx.add(ids.monoNs() - startMono)
|
||||||
|
rtts.add(r.rttMs)
|
||||||
|
r.observation?.observedPort?.let { observedPorts.add(it) }
|
||||||
|
} else {
|
||||||
|
tRx.add(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val sent = cfg.echoCount
|
||||||
|
val received = rtts.size
|
||||||
|
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
|
||||||
|
val natRebinding = observedPorts.size > 1
|
||||||
|
|
||||||
|
val evidence: JsonObject = TrainEvidence(
|
||||||
|
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
|
||||||
|
).toEvidence()
|
||||||
|
|
||||||
|
val metrics: JsonObject = json.encodeToJsonElement(
|
||||||
|
EchoMetrics(
|
||||||
|
sent = sent, received = received, lossPct = round1(lossPct),
|
||||||
|
rttMsMin = rtts.minOrNull()?.let(::round1),
|
||||||
|
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
|
||||||
|
rttMsMax = rtts.maxOrNull()?.let(::round1),
|
||||||
|
observedPorts = observedPorts.toList(),
|
||||||
|
natRebindingDetected = natRebinding,
|
||||||
|
)
|
||||||
|
) as JsonObject
|
||||||
|
|
||||||
|
val status = when {
|
||||||
|
received == 0 -> TestStatus.FAILED
|
||||||
|
received < sent -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
val test = Test(
|
||||||
|
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
|
||||||
|
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
|
||||||
|
evidence = evidence, metrics = metrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
val findings = ArrayList<Finding>()
|
||||||
|
if (received == 0) {
|
||||||
|
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
|
||||||
|
"No UDP echo replies from the server",
|
||||||
|
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
|
||||||
|
} else if (lossPct >= 20.0) {
|
||||||
|
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
|
||||||
|
"High UDP loss to the server (${round1(lossPct)}%)",
|
||||||
|
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
|
||||||
|
}
|
||||||
|
if (natRebinding) {
|
||||||
|
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
|
||||||
|
"NAT remapped the UDP source port mid-flow",
|
||||||
|
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
|
||||||
|
}
|
||||||
|
return test to findings
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
|
||||||
|
Finding(
|
||||||
|
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val Wire_HEADER = 32
|
||||||
|
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
|
||||||
|
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
|
||||||
|
*/
|
||||||
|
interface IdSource {
|
||||||
|
fun uuid(): String
|
||||||
|
fun monoNs(): Long
|
||||||
|
fun nowWall(): String
|
||||||
|
}
|
||||||
|
|
||||||
|
class SystemIdSource : IdSource {
|
||||||
|
override fun uuid(): String = UUID.randomUUID().toString()
|
||||||
|
override fun monoNs(): Long = System.nanoTime()
|
||||||
|
override fun nowWall(): String = Instant.now().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
|
||||||
|
@Serializable
|
||||||
|
data class EchoMetrics(
|
||||||
|
val sent: Int,
|
||||||
|
val received: Int,
|
||||||
|
@SerialName("loss_pct") val lossPct: Double,
|
||||||
|
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
|
||||||
|
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
|
||||||
|
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
|
||||||
|
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
|
||||||
|
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.protocol.ControlClient
|
||||||
|
import app.echo_lot.protocol.ProbeSession
|
||||||
|
import app.echo_lot.protocol.Wire
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
|
||||||
|
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
|
||||||
|
*
|
||||||
|
* This is the direction a client cannot measure alone — only the far end can push large or
|
||||||
|
* numerous packets toward it — so it is also the direction that needs the anti-amplification
|
||||||
|
* grant, and this test is the proof that the grant path works end to end.
|
||||||
|
*/
|
||||||
|
class LiveGrantedTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||||
|
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downstreamTrainAndBigSend() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin))
|
||||||
|
val profile = control.profile(cred)
|
||||||
|
println("capabilities: ${profile.capabilities}")
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
|
||||||
|
ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
|
||||||
|
val echo = ps.echo()
|
||||||
|
println("primed with echo rtt=${echo?.rttMs}")
|
||||||
|
|
||||||
|
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
|
||||||
|
val dtResp = control.action(
|
||||||
|
cred, session.sessionId,
|
||||||
|
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
|
||||||
|
)
|
||||||
|
println("downtrain accepted: ${dtResp.take(160)}")
|
||||||
|
val down = ps.collectGranted(windowMs = 4000)
|
||||||
|
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
|
||||||
|
val seqs = down.map { it.seq }.toSet()
|
||||||
|
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
|
||||||
|
"sizes=${down.map { it.sizeBytes }.distinct()}")
|
||||||
|
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
|
||||||
|
|
||||||
|
// --- big_send: which downstream sizes survive? ---
|
||||||
|
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
|
||||||
|
val bsResp = control.action(
|
||||||
|
cred, session.sessionId,
|
||||||
|
"""{"action":"big_send","sizes_bytes":${sizes}}""",
|
||||||
|
)
|
||||||
|
println("big_send accepted: ${bsResp.take(160)}")
|
||||||
|
val big = ps.collectGranted(windowMs = 4000)
|
||||||
|
.filter { it.type == Wire.TYPE_BIG_SEND }
|
||||||
|
val arrived = big.map { it.sizeBytes }.sorted()
|
||||||
|
println("big_send arrived sizes: $arrived (requested $sizes)")
|
||||||
|
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
|
||||||
|
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
|
||||||
|
}
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.engine
|
||||||
|
|
||||||
|
import app.echo_lot.measurement.*
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the full server-facing engine against a live server and validates the produced
|
||||||
|
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
|
||||||
|
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
|
||||||
|
*/
|
||||||
|
class LiveMeasurementTest {
|
||||||
|
|
||||||
|
private val url = System.getenv("ECHOLOT_LIVE_URL")
|
||||||
|
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
|
||||||
|
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
|
||||||
|
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
|
||||||
|
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun producesValidDocumentFromLiveServer() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
val engine = ServerMeasurement(
|
||||||
|
ids = SystemIdSource(),
|
||||||
|
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
|
||||||
|
device = DeviceInfo("test", "jvm", 0, "n/a"),
|
||||||
|
)
|
||||||
|
val doc = engine.run(
|
||||||
|
ServerMeasurement.Config(
|
||||||
|
controlUrl = url, pins = setOf(pin), credential = cred,
|
||||||
|
target = target, udpHost = host, udpPort = port, echoCount = 20,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The document must round-trip and carry the expected structure.
|
||||||
|
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
|
||||||
|
|
||||||
|
assertEquals(1, doc.serverSessions.size)
|
||||||
|
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
|
||||||
|
val test = doc.tests.single()
|
||||||
|
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
|
||||||
|
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
|
||||||
|
"expected replies from live server, got ${test.status}")
|
||||||
|
|
||||||
|
val metrics = Json.parseToJsonElement(test.metrics.toString())
|
||||||
|
println("metrics: $metrics")
|
||||||
|
assertTrue(metrics.toString().contains("rtt_ms_avg"))
|
||||||
|
|
||||||
|
assertTrue(doc.summary != null)
|
||||||
|
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
|
||||||
|
println("summary: ${doc.summary}")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package measurement models one measurement run (measurement-schema.md) — the archived,
|
||||||
|
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
|
||||||
|
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
|
||||||
|
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
|
||||||
|
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MeasurementDocument(
|
||||||
|
val schema: String = "echolot/measurement",
|
||||||
|
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
|
||||||
|
val run: Run,
|
||||||
|
val networks: List<Network> = emptyList(),
|
||||||
|
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
|
||||||
|
val tests: List<Test> = emptyList(),
|
||||||
|
val findings: List<Finding> = emptyList(),
|
||||||
|
val summary: Summary? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Run(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val trigger: Trigger,
|
||||||
|
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||||
|
@SerialName("ended_at") val endedAt: String? = null,
|
||||||
|
val clock: Clock,
|
||||||
|
val app: AppInfo,
|
||||||
|
val device: DeviceInfo,
|
||||||
|
val tiers: Tiers,
|
||||||
|
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Trigger {
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("scheduled") SCHEDULED,
|
||||||
|
@SerialName("monitor") MONITOR,
|
||||||
|
@SerialName("peer") PEER,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
|
||||||
|
* all math uses *_mono_ns relative to that monotonic origin. */
|
||||||
|
@Serializable
|
||||||
|
data class Clock(
|
||||||
|
@SerialName("mono_origin_wall") val monoOriginWall: String,
|
||||||
|
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
|
||||||
|
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppInfo(
|
||||||
|
val version: String,
|
||||||
|
val build: Int,
|
||||||
|
val git: String? = null,
|
||||||
|
val flavor: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceInfo(
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
@SerialName("android_sdk") val androidSdk: Int,
|
||||||
|
@SerialName("android_release") val androidRelease: String,
|
||||||
|
@SerialName("security_patch") val securityPatch: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What each tier was *available*; each test records what it *used*. */
|
||||||
|
@Serializable
|
||||||
|
data class Tiers(
|
||||||
|
val app: Boolean = true,
|
||||||
|
val shizuku: Boolean = false,
|
||||||
|
val root: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ServerSession(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("profile_id") val profileId: String? = null,
|
||||||
|
@SerialName("profile_name") val profileName: String? = null,
|
||||||
|
@SerialName("control_url") val controlUrl: String,
|
||||||
|
@SerialName("server_version") val serverVersion: String? = null,
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
val target: SessionTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionTarget(
|
||||||
|
val ip4: String? = null,
|
||||||
|
val ip6: String? = null,
|
||||||
|
@SerialName("udp_port") val udpPort: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
|
||||||
|
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
|
||||||
|
* means the columnar/traceroute/resolver contracts live in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
|
||||||
|
inline fun <reified T> T.toEvidence(): JsonObject =
|
||||||
|
evidenceJson.encodeToJsonElement(this) as JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
|
||||||
|
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
|
||||||
|
* columns use the server session epoch; only differences within one clock are meaningful unless a
|
||||||
|
* time.server_offset test maps them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TrainEvidence(
|
||||||
|
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
|
||||||
|
val seq: List<Int>,
|
||||||
|
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
|
||||||
|
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: List<Int>,
|
||||||
|
@SerialName("dscp_sent") val dscpSent: Int? = null,
|
||||||
|
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ecn_sent") val ecnSent: Int? = null,
|
||||||
|
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
|
||||||
|
@Serializable
|
||||||
|
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Flow(
|
||||||
|
@SerialName("src_port") val srcPort: Int,
|
||||||
|
@SerialName("dst_port") val dstPort: Int,
|
||||||
|
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Hop(val ttl: Int, val probes: List<HopProbe>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HopProbe(
|
||||||
|
@SerialName("reply_from") val replyFrom: String? = null,
|
||||||
|
@SerialName("rtt_ns") val rttNs: Long? = null,
|
||||||
|
val icmp: String? = null,
|
||||||
|
@SerialName("reply_ttl") val replyTtl: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Resolver under test (§6.4); every dns.* test carries this in params. */
|
||||||
|
@Serializable
|
||||||
|
data class ResolverSpec(
|
||||||
|
val source: ResolverSource,
|
||||||
|
val address: String? = null,
|
||||||
|
val port: Int = 53,
|
||||||
|
val transport: String, // do53-udp | do53-tcp | dot | doh
|
||||||
|
@SerialName("doh_url") val dohUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResolverSource {
|
||||||
|
@SerialName("system") SYSTEM,
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("server-recursive") SERVER_RECURSIVE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
|
||||||
|
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
|
||||||
|
@Serializable
|
||||||
|
data class Finding(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val code: String, // stable registry (findings-registry.md), lint-rule style
|
||||||
|
val category: Category,
|
||||||
|
val severity: Severity,
|
||||||
|
val confidence: Confidence,
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
|
||||||
|
val recommendation: String? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EvidenceRef(val test: String, val pointer: String? = null)
|
||||||
|
|
||||||
|
/** Fixed §7.2 categories; each maps to one traffic light. */
|
||||||
|
@Serializable
|
||||||
|
enum class Category {
|
||||||
|
@SerialName("connectivity") CONNECTIVITY,
|
||||||
|
@SerialName("dns") DNS,
|
||||||
|
@SerialName("nat") NAT,
|
||||||
|
@SerialName("mtu") MTU,
|
||||||
|
@SerialName("ipv6") IPV6,
|
||||||
|
@SerialName("security") SECURITY,
|
||||||
|
@SerialName("performance") PERFORMANCE,
|
||||||
|
@SerialName("local") LOCAL,
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
|
||||||
|
@Serializable
|
||||||
|
enum class Severity(val rank: Int) {
|
||||||
|
@SerialName("critical") CRITICAL(4),
|
||||||
|
@SerialName("high") HIGH(3),
|
||||||
|
@SerialName("medium") MEDIUM(2),
|
||||||
|
@SerialName("low") LOW(1),
|
||||||
|
@SerialName("info") INFO(0);
|
||||||
|
|
||||||
|
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
|
||||||
|
fun toLight(): Verdict = when (this) {
|
||||||
|
CRITICAL, HIGH -> Verdict.RED
|
||||||
|
MEDIUM, LOW -> Verdict.YELLOW
|
||||||
|
INFO -> Verdict.GREEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Confidence {
|
||||||
|
@SerialName("high") HIGH,
|
||||||
|
@SerialName("medium") MEDIUM,
|
||||||
|
@SerialName("low") LOW,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
|
||||||
|
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
|
||||||
|
@Serializable
|
||||||
|
data class Network(
|
||||||
|
val id: String,
|
||||||
|
val transport: Transport,
|
||||||
|
@SerialName("interface") val iface: String? = null,
|
||||||
|
val link: Link,
|
||||||
|
val wifi: Wifi? = null,
|
||||||
|
val cellular: Cellular? = null,
|
||||||
|
val changes: List<NetworkChange> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Transport {
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
@SerialName("cellular") CELLULAR,
|
||||||
|
@SerialName("ethernet") ETHERNET,
|
||||||
|
@SerialName("vpn") VPN,
|
||||||
|
@SerialName("other") OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Link(
|
||||||
|
val mtu: Int? = null,
|
||||||
|
val addresses: List<Address> = emptyList(),
|
||||||
|
val routes: List<Route> = emptyList(),
|
||||||
|
val dns: DnsConfig? = null,
|
||||||
|
val dhcp: Dhcp? = null,
|
||||||
|
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Address(
|
||||||
|
val addr: String, // ip4 | ip6 (logical type, §8)
|
||||||
|
@SerialName("prefix_len") val prefixLen: Int,
|
||||||
|
val scope: String? = null,
|
||||||
|
val flags: List<String> = emptyList(),
|
||||||
|
@SerialName("valid_lft_s") val validLftS: Long? = null,
|
||||||
|
@SerialName("pref_lft_s") val prefLftS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Route(
|
||||||
|
val dst: String,
|
||||||
|
val gateway: String? = null,
|
||||||
|
val iface: String? = null,
|
||||||
|
val proto: RouteProto? = null, // shizuku tier; null = not observed
|
||||||
|
@SerialName("expires_s") val expiresS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RouteProto {
|
||||||
|
@SerialName("dhcp") DHCP,
|
||||||
|
@SerialName("ra") RA,
|
||||||
|
@SerialName("static") STATIC,
|
||||||
|
@SerialName("unknown") UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DnsConfig(
|
||||||
|
val servers: List<String> = emptyList(),
|
||||||
|
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
|
||||||
|
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
|
||||||
|
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
|
||||||
|
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CaptivePortal(
|
||||||
|
val detected: Boolean = false,
|
||||||
|
@SerialName("api_url") val apiUrl: String? = null,
|
||||||
|
@SerialName("venue_url") val venueUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Wifi(
|
||||||
|
val ssid: String? = null, // ssid (logical type)
|
||||||
|
val bssid: String? = null, // bssid (logical type)
|
||||||
|
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
|
||||||
|
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
|
||||||
|
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
|
||||||
|
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
|
||||||
|
val standard: String? = null,
|
||||||
|
val security: String? = null,
|
||||||
|
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Cellular(
|
||||||
|
val rat: String? = null,
|
||||||
|
val operator: String? = null,
|
||||||
|
val band: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkChange(
|
||||||
|
@SerialName("at_mono_ns") val atMonoNs: Long,
|
||||||
|
val kind: String, // lost | gained | link_changed
|
||||||
|
val detail: JsonObject? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Verdict {
|
||||||
|
@SerialName("green") GREEN,
|
||||||
|
@SerialName("yellow") YELLOW,
|
||||||
|
@SerialName("red") RED,
|
||||||
|
@SerialName("inconclusive") INCONCLUSIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Summary(
|
||||||
|
val overall: Verdict,
|
||||||
|
val categories: Map<String, CategorySummary>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CategorySummary(
|
||||||
|
val verdict: Verdict,
|
||||||
|
@SerialName("worst_finding") val worstFinding: String? = null,
|
||||||
|
@SerialName("tests_run") val testsRun: Int,
|
||||||
|
@SerialName("tests_failed") val testsFailed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
|
||||||
|
*
|
||||||
|
* - A category's verdict = the light of its worst-severity finding
|
||||||
|
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||||
|
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||||
|
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
||||||
|
*
|
||||||
|
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||||
|
* findings or tests appear in the summary.
|
||||||
|
*/
|
||||||
|
object Verdicts {
|
||||||
|
|
||||||
|
private fun isInconclusiveTest(s: TestStatus) =
|
||||||
|
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||||
|
|
||||||
|
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
||||||
|
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||||
|
val findingsByCat = findings.groupBy { it.category }
|
||||||
|
val categories = (testsByCat.keys + findingsByCat.keys)
|
||||||
|
|
||||||
|
val perCat = LinkedHashMap<String, CategorySummary>()
|
||||||
|
for (cat in Category.entries) {
|
||||||
|
if (cat !in categories) continue
|
||||||
|
val catTests = testsByCat[cat].orEmpty()
|
||||||
|
val catFindings = findingsByCat[cat].orEmpty()
|
||||||
|
|
||||||
|
val failed = catTests.count { isInconclusiveTest(it.status) }
|
||||||
|
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
|
||||||
|
|
||||||
|
val worst = catFindings.maxByOrNull { it.severity.rank }
|
||||||
|
val verdict = when {
|
||||||
|
inconclusive -> Verdict.INCONCLUSIVE
|
||||||
|
worst == null -> Verdict.GREEN
|
||||||
|
else -> worst.severity.toLight()
|
||||||
|
}
|
||||||
|
perCat[serialName(cat)] = CategorySummary(
|
||||||
|
verdict = verdict,
|
||||||
|
worstFinding = worst?.id,
|
||||||
|
testsRun = catTests.size,
|
||||||
|
testsFailed = failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val overall = deriveOverall(perCat.values)
|
||||||
|
return Summary(overall = overall, categories = perCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall = worst light; inconclusive only if every category is inconclusive. */
|
||||||
|
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
|
||||||
|
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
|
||||||
|
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
|
||||||
|
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
|
||||||
|
// Non-inconclusive categories decide the overall light.
|
||||||
|
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
|
||||||
|
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serialName(cat: Category): String = cat.name.lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
|
||||||
|
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
|
||||||
|
* per-test-type JSON, so they are carried as JsonObject. */
|
||||||
|
@Serializable
|
||||||
|
data class Test(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val type: String, // TestType registry (§6.1)
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
|
||||||
|
val tier: Tier,
|
||||||
|
@SerialName("started_mono_ns") val startedMonoNs: Long,
|
||||||
|
@SerialName("ended_mono_ns") val endedMonoNs: Long,
|
||||||
|
val status: TestStatus,
|
||||||
|
val error: TestError? = null,
|
||||||
|
val params: JsonObject? = null,
|
||||||
|
val evidence: JsonObject? = null,
|
||||||
|
val metrics: JsonObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Tier {
|
||||||
|
@SerialName("app") APP,
|
||||||
|
@SerialName("shizuku") SHIZUKU,
|
||||||
|
@SerialName("root") ROOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class TestStatus {
|
||||||
|
@SerialName("ok") OK,
|
||||||
|
@SerialName("failed") FAILED,
|
||||||
|
@SerialName("unsupported") UNSUPPORTED,
|
||||||
|
@SerialName("skipped") SKIPPED,
|
||||||
|
@SerialName("partial") PARTIAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestError(val code: String, val detail: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
|
||||||
|
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
|
||||||
|
* the fixed §7.2 categories for verdict rollup.
|
||||||
|
*/
|
||||||
|
object TestType {
|
||||||
|
// link
|
||||||
|
const val LINK_SNAPSHOT = "link.snapshot"
|
||||||
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
|
const val LINK_IP_MONITOR = "link.ip_monitor"
|
||||||
|
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
|
||||||
|
const val LINK_RA_SOURCE = "link.ra_source"
|
||||||
|
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||||
|
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||||
|
// icmp
|
||||||
|
const val ICMP_PING4 = "icmp.ping4"
|
||||||
|
const val ICMP_PING6 = "icmp.ping6"
|
||||||
|
// trace
|
||||||
|
const val TRACEROUTE_UDP4 = "traceroute.udp4"
|
||||||
|
const val TRACEROUTE_UDP6 = "traceroute.udp6"
|
||||||
|
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
|
||||||
|
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
|
||||||
|
// train
|
||||||
|
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
|
||||||
|
// mtu
|
||||||
|
const val MTU_PMTUD_UP = "mtu.pmtud_up"
|
||||||
|
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
|
||||||
|
const val MTU_BLACKHOLE = "mtu.blackhole"
|
||||||
|
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
|
||||||
|
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
|
||||||
|
// nat
|
||||||
|
const val NAT_STUN_5780 = "nat.stun_5780"
|
||||||
|
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
|
||||||
|
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
|
||||||
|
const val NAT_HAIRPIN = "nat.hairpin"
|
||||||
|
const val NAT_CONNECT_BACK = "nat.connect_back"
|
||||||
|
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
|
||||||
|
// dns
|
||||||
|
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||||
|
const val DNS_CANARY = "dns.canary"
|
||||||
|
const val DNS_INTERCEPTION = "dns.interception"
|
||||||
|
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||||
|
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||||
|
const val DNS_DNSSEC = "dns.dnssec"
|
||||||
|
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
|
||||||
|
const val DNS_REBIND_FILTER = "dns.rebind_filter"
|
||||||
|
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
|
||||||
|
const val DNS_DNS64 = "dns.dns64"
|
||||||
|
const val DNS_COMPARE = "dns.compare"
|
||||||
|
// sec
|
||||||
|
const val SEC_TLS_REFERENCE = "sec.tls_reference"
|
||||||
|
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
|
||||||
|
const val SEC_HTTP_ECHO = "sec.http_echo"
|
||||||
|
const val SEC_SNI_FILTER = "sec.sni_filter"
|
||||||
|
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
|
||||||
|
const val SEC_ARP_WATCH = "sec.arp_watch"
|
||||||
|
// port
|
||||||
|
const val PORT_REACH_SWEEP = "port.reach_sweep"
|
||||||
|
const val PORT_UDP_USABILITY = "port.udp_usability"
|
||||||
|
// perf
|
||||||
|
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
|
||||||
|
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
|
||||||
|
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
|
||||||
|
const val PERF_RRC_LATENCY = "perf.rrc_latency"
|
||||||
|
// v6
|
||||||
|
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
|
||||||
|
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
|
||||||
|
const val V6_BROKENNESS = "v6.brokenness"
|
||||||
|
const val V6_NAT64_CLAT = "v6.nat64_clat"
|
||||||
|
// wifi
|
||||||
|
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
|
||||||
|
const val WIFI_ROAM_LOG = "wifi.roam_log"
|
||||||
|
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
|
||||||
|
// local
|
||||||
|
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||||
|
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||||
|
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||||
|
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||||
|
const val LOCAL_NTP = "local.ntp"
|
||||||
|
// peer
|
||||||
|
const val PEER_REACHABILITY = "peer.reachability"
|
||||||
|
const val PEER_ISOLATION = "peer.isolation"
|
||||||
|
const val PEER_MULTICAST = "peer.multicast"
|
||||||
|
const val PEER_LAN_TRAIN = "peer.lan_train"
|
||||||
|
const val PEER_LEASE_DIFF = "peer.lease_diff"
|
||||||
|
// time
|
||||||
|
const val TIME_SERVER_OFFSET = "time.server_offset"
|
||||||
|
|
||||||
|
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
|
||||||
|
fun category(type: String): Category = when (type.substringBefore('.')) {
|
||||||
|
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
|
||||||
|
"dns" -> Category.DNS
|
||||||
|
"nat" -> Category.NAT
|
||||||
|
"mtu" -> Category.MTU
|
||||||
|
"v6" -> Category.IPV6
|
||||||
|
"sec" -> Category.SECURITY
|
||||||
|
"perf" -> Category.PERFORMANCE
|
||||||
|
"local", "peer" -> Category.LOCAL
|
||||||
|
"wifi" -> Category.WIFI
|
||||||
|
else -> Category.CONNECTIVITY
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SerializationTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun documentRoundTrips() {
|
||||||
|
val doc = MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = "0198c5f2-0000-7000-8000-000000000000",
|
||||||
|
trigger = Trigger.MANUAL,
|
||||||
|
startedAt = "2026-07-31T14:03:21.114Z",
|
||||||
|
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
|
||||||
|
app = AppInfo(version = "0.1.0", build = 1),
|
||||||
|
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
|
||||||
|
tiers = Tiers(app = true, shizuku = true),
|
||||||
|
),
|
||||||
|
networks = listOf(
|
||||||
|
Network(
|
||||||
|
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
|
||||||
|
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
|
||||||
|
wifi = Wifi(ssid = "example", rssiDbm = -54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tests = listOf(
|
||||||
|
Test(
|
||||||
|
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
|
||||||
|
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
|
||||||
|
evidence = TrainEvidence(
|
||||||
|
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
|
||||||
|
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
|
||||||
|
).toEvidence(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
|
||||||
|
assertEquals(doc.run.id, decoded.run.id)
|
||||||
|
assertEquals(Transport.WIFI, decoded.networks[0].transport)
|
||||||
|
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
|
||||||
|
// snake_case field names on the wire
|
||||||
|
assertTrue(encoded.contains("\"schema_version\""))
|
||||||
|
assertTrue(encoded.contains("\"mono_origin_wall\""))
|
||||||
|
assertTrue(encoded.contains("\"t_tx_ns\""))
|
||||||
|
// null preserved at train index 1
|
||||||
|
assertTrue(encoded.contains("[16500000,null]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun findingRequiresEvidence() {
|
||||||
|
try {
|
||||||
|
Finding(
|
||||||
|
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
|
||||||
|
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
|
||||||
|
)
|
||||||
|
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// expected — a finding with no evidence is invalid (§7.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class VerdictsTest {
|
||||||
|
|
||||||
|
private fun test(type: String, status: TestStatus, id: String = type): Test =
|
||||||
|
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
|
||||||
|
|
||||||
|
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
|
||||||
|
Finding(
|
||||||
|
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryLightFromWorstSeverity() {
|
||||||
|
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.LOW),
|
||||||
|
finding(Category.DNS, Severity.HIGH), // worst → red
|
||||||
|
finding(Category.DNS, Severity.INFO),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun noFindingsIsGreen() {
|
||||||
|
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun mediumAndLowAreYellow() {
|
||||||
|
val s = Verdicts.derive(
|
||||||
|
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
|
||||||
|
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
|
||||||
|
)
|
||||||
|
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun majorityFailedIsInconclusive() {
|
||||||
|
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
|
||||||
|
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(2, s.categories["dns"]!!.testsFailed)
|
||||||
|
assertEquals(3, s.categories["dns"]!!.testsRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun exactlyHalfFailedIsNotInconclusive() {
|
||||||
|
// 1 of 2 failed → not > 50% → the finding decides.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
|
||||||
|
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallIsWorstCategory() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
|
||||||
|
)
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.MEDIUM), // yellow
|
||||||
|
finding(Category.MTU, Severity.CRITICAL), // red
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallInconclusiveOnlyWhenAllAre() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, emptyList())
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryMappingCoversFamilies() {
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
|
||||||
|
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
|
||||||
|
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
|
||||||
|
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
|
||||||
|
// the Android app modules can depend on it and it stays unit-testable (incl.
|
||||||
|
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
|
||||||
|
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
// Build with the available JDK (Android Studio's JBR is 21) but emit
|
||||||
|
// Java-17 bytecode so the Android app modules can consume this library.
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package measurement models one measurement run (measurement-schema.md) — the archived,
|
||||||
|
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
|
||||||
|
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
|
||||||
|
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
|
||||||
|
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MeasurementDocument(
|
||||||
|
val schema: String = "echolot/measurement",
|
||||||
|
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
|
||||||
|
val run: Run,
|
||||||
|
val networks: List<Network> = emptyList(),
|
||||||
|
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
|
||||||
|
val tests: List<Test> = emptyList(),
|
||||||
|
val findings: List<Finding> = emptyList(),
|
||||||
|
val summary: Summary? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Run(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val trigger: Trigger,
|
||||||
|
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
|
||||||
|
@SerialName("ended_at") val endedAt: String? = null,
|
||||||
|
val clock: Clock,
|
||||||
|
val app: AppInfo,
|
||||||
|
val device: DeviceInfo,
|
||||||
|
val tiers: Tiers,
|
||||||
|
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
|
||||||
|
val notes: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Trigger {
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("scheduled") SCHEDULED,
|
||||||
|
@SerialName("monitor") MONITOR,
|
||||||
|
@SerialName("peer") PEER,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
|
||||||
|
* all math uses *_mono_ns relative to that monotonic origin. */
|
||||||
|
@Serializable
|
||||||
|
data class Clock(
|
||||||
|
@SerialName("mono_origin_wall") val monoOriginWall: String,
|
||||||
|
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
|
||||||
|
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AppInfo(
|
||||||
|
val version: String,
|
||||||
|
val build: Int,
|
||||||
|
val git: String? = null,
|
||||||
|
val flavor: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DeviceInfo(
|
||||||
|
val manufacturer: String,
|
||||||
|
val model: String,
|
||||||
|
@SerialName("android_sdk") val androidSdk: Int,
|
||||||
|
@SerialName("android_release") val androidRelease: String,
|
||||||
|
@SerialName("security_patch") val securityPatch: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What each tier was *available*; each test records what it *used*. */
|
||||||
|
@Serializable
|
||||||
|
data class Tiers(
|
||||||
|
val app: Boolean = true,
|
||||||
|
val shizuku: Boolean = false,
|
||||||
|
val root: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ServerSession(
|
||||||
|
val id: String,
|
||||||
|
@SerialName("profile_id") val profileId: String? = null,
|
||||||
|
@SerialName("profile_name") val profileName: String? = null,
|
||||||
|
@SerialName("control_url") val controlUrl: String,
|
||||||
|
@SerialName("server_version") val serverVersion: String? = null,
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
val target: SessionTarget,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionTarget(
|
||||||
|
val ip4: String? = null,
|
||||||
|
val ip6: String? = null,
|
||||||
|
@SerialName("udp_port") val udpPort: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
|
||||||
|
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
|
||||||
|
* means the columnar/traceroute/resolver contracts live in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@PublishedApi
|
||||||
|
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
|
||||||
|
|
||||||
|
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
|
||||||
|
inline fun <reified T> T.toEvidence(): JsonObject =
|
||||||
|
evidenceJson.encodeToJsonElement(this) as JsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
|
||||||
|
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
|
||||||
|
* columns use the server session epoch; only differences within one clock are meaningful unless a
|
||||||
|
* time.server_offset test maps them.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class TrainEvidence(
|
||||||
|
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
|
||||||
|
val seq: List<Int>,
|
||||||
|
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
|
||||||
|
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
|
||||||
|
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
|
||||||
|
@SerialName("size_bytes") val sizeBytes: List<Int>,
|
||||||
|
@SerialName("dscp_sent") val dscpSent: Int? = null,
|
||||||
|
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ecn_sent") val ecnSent: Int? = null,
|
||||||
|
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
|
||||||
|
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
|
||||||
|
@Serializable
|
||||||
|
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Flow(
|
||||||
|
@SerialName("src_port") val srcPort: Int,
|
||||||
|
@SerialName("dst_port") val dstPort: Int,
|
||||||
|
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Hop(val ttl: Int, val probes: List<HopProbe>)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HopProbe(
|
||||||
|
@SerialName("reply_from") val replyFrom: String? = null,
|
||||||
|
@SerialName("rtt_ns") val rttNs: Long? = null,
|
||||||
|
val icmp: String? = null,
|
||||||
|
@SerialName("reply_ttl") val replyTtl: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Resolver under test (§6.4); every dns.* test carries this in params. */
|
||||||
|
@Serializable
|
||||||
|
data class ResolverSpec(
|
||||||
|
val source: ResolverSource,
|
||||||
|
val address: String? = null,
|
||||||
|
val port: Int = 53,
|
||||||
|
val transport: String, // do53-udp | do53-tcp | dot | doh
|
||||||
|
@SerialName("doh_url") val dohUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class ResolverSource {
|
||||||
|
@SerialName("system") SYSTEM,
|
||||||
|
@SerialName("manual") MANUAL,
|
||||||
|
@SerialName("server-recursive") SERVER_RECURSIVE,
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
|
||||||
|
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
|
||||||
|
@Serializable
|
||||||
|
data class Finding(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val code: String, // stable registry (findings-registry.md), lint-rule style
|
||||||
|
val category: Category,
|
||||||
|
val severity: Severity,
|
||||||
|
val confidence: Confidence,
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
|
||||||
|
val recommendation: String? = null,
|
||||||
|
) {
|
||||||
|
init {
|
||||||
|
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EvidenceRef(val test: String, val pointer: String? = null)
|
||||||
|
|
||||||
|
/** Fixed §7.2 categories; each maps to one traffic light. */
|
||||||
|
@Serializable
|
||||||
|
enum class Category {
|
||||||
|
@SerialName("connectivity") CONNECTIVITY,
|
||||||
|
@SerialName("dns") DNS,
|
||||||
|
@SerialName("nat") NAT,
|
||||||
|
@SerialName("mtu") MTU,
|
||||||
|
@SerialName("ipv6") IPV6,
|
||||||
|
@SerialName("security") SECURITY,
|
||||||
|
@SerialName("performance") PERFORMANCE,
|
||||||
|
@SerialName("local") LOCAL,
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
|
||||||
|
@Serializable
|
||||||
|
enum class Severity(val rank: Int) {
|
||||||
|
@SerialName("critical") CRITICAL(4),
|
||||||
|
@SerialName("high") HIGH(3),
|
||||||
|
@SerialName("medium") MEDIUM(2),
|
||||||
|
@SerialName("low") LOW(1),
|
||||||
|
@SerialName("info") INFO(0);
|
||||||
|
|
||||||
|
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
|
||||||
|
fun toLight(): Verdict = when (this) {
|
||||||
|
CRITICAL, HIGH -> Verdict.RED
|
||||||
|
MEDIUM, LOW -> Verdict.YELLOW
|
||||||
|
INFO -> Verdict.GREEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Confidence {
|
||||||
|
@SerialName("high") HIGH,
|
||||||
|
@SerialName("medium") MEDIUM,
|
||||||
|
@SerialName("low") LOW,
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
|
||||||
|
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
|
||||||
|
@Serializable
|
||||||
|
data class Network(
|
||||||
|
val id: String,
|
||||||
|
val transport: Transport,
|
||||||
|
@SerialName("interface") val iface: String? = null,
|
||||||
|
val link: Link,
|
||||||
|
val wifi: Wifi? = null,
|
||||||
|
val cellular: Cellular? = null,
|
||||||
|
val changes: List<NetworkChange> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Transport {
|
||||||
|
@SerialName("wifi") WIFI,
|
||||||
|
@SerialName("cellular") CELLULAR,
|
||||||
|
@SerialName("ethernet") ETHERNET,
|
||||||
|
@SerialName("vpn") VPN,
|
||||||
|
@SerialName("other") OTHER,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Link(
|
||||||
|
val mtu: Int? = null,
|
||||||
|
val addresses: List<Address> = emptyList(),
|
||||||
|
val routes: List<Route> = emptyList(),
|
||||||
|
val dns: DnsConfig? = null,
|
||||||
|
val dhcp: Dhcp? = null,
|
||||||
|
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Address(
|
||||||
|
val addr: String, // ip4 | ip6 (logical type, §8)
|
||||||
|
@SerialName("prefix_len") val prefixLen: Int,
|
||||||
|
val scope: String? = null,
|
||||||
|
val flags: List<String> = emptyList(),
|
||||||
|
@SerialName("valid_lft_s") val validLftS: Long? = null,
|
||||||
|
@SerialName("pref_lft_s") val prefLftS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Route(
|
||||||
|
val dst: String,
|
||||||
|
val gateway: String? = null,
|
||||||
|
val iface: String? = null,
|
||||||
|
val proto: RouteProto? = null, // shizuku tier; null = not observed
|
||||||
|
@SerialName("expires_s") val expiresS: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class RouteProto {
|
||||||
|
@SerialName("dhcp") DHCP,
|
||||||
|
@SerialName("ra") RA,
|
||||||
|
@SerialName("static") STATIC,
|
||||||
|
@SerialName("unknown") UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DnsConfig(
|
||||||
|
val servers: List<String> = emptyList(),
|
||||||
|
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
|
||||||
|
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
|
||||||
|
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
|
||||||
|
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CaptivePortal(
|
||||||
|
val detected: Boolean = false,
|
||||||
|
@SerialName("api_url") val apiUrl: String? = null,
|
||||||
|
@SerialName("venue_url") val venueUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Wifi(
|
||||||
|
val ssid: String? = null, // ssid (logical type)
|
||||||
|
val bssid: String? = null, // bssid (logical type)
|
||||||
|
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
|
||||||
|
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
|
||||||
|
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
|
||||||
|
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
|
||||||
|
val standard: String? = null,
|
||||||
|
val security: String? = null,
|
||||||
|
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Cellular(
|
||||||
|
val rat: String? = null,
|
||||||
|
val operator: String? = null,
|
||||||
|
val band: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NetworkChange(
|
||||||
|
@SerialName("at_mono_ns") val atMonoNs: Long,
|
||||||
|
val kind: String, // lost | gained | link_changed
|
||||||
|
val detail: JsonObject? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Verdict {
|
||||||
|
@SerialName("green") GREEN,
|
||||||
|
@SerialName("yellow") YELLOW,
|
||||||
|
@SerialName("red") RED,
|
||||||
|
@SerialName("inconclusive") INCONCLUSIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Summary(
|
||||||
|
val overall: Verdict,
|
||||||
|
val categories: Map<String, CategorySummary>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CategorySummary(
|
||||||
|
val verdict: Verdict,
|
||||||
|
@SerialName("worst_finding") val worstFinding: String? = null,
|
||||||
|
@SerialName("tests_run") val testsRun: Int,
|
||||||
|
@SerialName("tests_failed") val testsFailed: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
|
||||||
|
*
|
||||||
|
* - A category's verdict = the light of its worst-severity finding
|
||||||
|
* (critical|high → red, medium|low → yellow, info/none → green).
|
||||||
|
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
|
||||||
|
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
|
||||||
|
*
|
||||||
|
* The mapping test-type → category comes from [TestType.category]. Only categories that have
|
||||||
|
* findings or tests appear in the summary.
|
||||||
|
*/
|
||||||
|
object Verdicts {
|
||||||
|
|
||||||
|
private fun isInconclusiveTest(s: TestStatus) =
|
||||||
|
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
|
||||||
|
|
||||||
|
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
|
||||||
|
val testsByCat = tests.groupBy { TestType.category(it.type) }
|
||||||
|
val findingsByCat = findings.groupBy { it.category }
|
||||||
|
val categories = (testsByCat.keys + findingsByCat.keys)
|
||||||
|
|
||||||
|
val perCat = LinkedHashMap<String, CategorySummary>()
|
||||||
|
for (cat in Category.entries) {
|
||||||
|
if (cat !in categories) continue
|
||||||
|
val catTests = testsByCat[cat].orEmpty()
|
||||||
|
val catFindings = findingsByCat[cat].orEmpty()
|
||||||
|
|
||||||
|
val failed = catTests.count { isInconclusiveTest(it.status) }
|
||||||
|
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
|
||||||
|
|
||||||
|
val worst = catFindings.maxByOrNull { it.severity.rank }
|
||||||
|
val verdict = when {
|
||||||
|
inconclusive -> Verdict.INCONCLUSIVE
|
||||||
|
worst == null -> Verdict.GREEN
|
||||||
|
else -> worst.severity.toLight()
|
||||||
|
}
|
||||||
|
perCat[serialName(cat)] = CategorySummary(
|
||||||
|
verdict = verdict,
|
||||||
|
worstFinding = worst?.id,
|
||||||
|
testsRun = catTests.size,
|
||||||
|
testsFailed = failed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val overall = deriveOverall(perCat.values)
|
||||||
|
return Summary(overall = overall, categories = perCat)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overall = worst light; inconclusive only if every category is inconclusive. */
|
||||||
|
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
|
||||||
|
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
|
||||||
|
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
|
||||||
|
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
|
||||||
|
// Non-inconclusive categories decide the overall light.
|
||||||
|
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
|
||||||
|
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serialName(cat: Category): String = cat.name.lowercase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
|
||||||
|
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
|
||||||
|
* per-test-type JSON, so they are carried as JsonObject. */
|
||||||
|
@Serializable
|
||||||
|
data class Test(
|
||||||
|
val id: String, // UUIDv7
|
||||||
|
val type: String, // TestType registry (§6.1)
|
||||||
|
@SerialName("network_ref") val networkRef: String? = null,
|
||||||
|
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
|
||||||
|
val tier: Tier,
|
||||||
|
@SerialName("started_mono_ns") val startedMonoNs: Long,
|
||||||
|
@SerialName("ended_mono_ns") val endedMonoNs: Long,
|
||||||
|
val status: TestStatus,
|
||||||
|
val error: TestError? = null,
|
||||||
|
val params: JsonObject? = null,
|
||||||
|
val evidence: JsonObject? = null,
|
||||||
|
val metrics: JsonObject? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class Tier {
|
||||||
|
@SerialName("app") APP,
|
||||||
|
@SerialName("shizuku") SHIZUKU,
|
||||||
|
@SerialName("root") ROOT,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class TestStatus {
|
||||||
|
@SerialName("ok") OK,
|
||||||
|
@SerialName("failed") FAILED,
|
||||||
|
@SerialName("unsupported") UNSUPPORTED,
|
||||||
|
@SerialName("skipped") SKIPPED,
|
||||||
|
@SerialName("partial") PARTIAL,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestError(val code: String, val detail: String? = null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
|
||||||
|
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
|
||||||
|
* the fixed §7.2 categories for verdict rollup.
|
||||||
|
*/
|
||||||
|
object TestType {
|
||||||
|
// link
|
||||||
|
const val LINK_SNAPSHOT = "link.snapshot"
|
||||||
|
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
|
||||||
|
const val LINK_IP_MONITOR = "link.ip_monitor"
|
||||||
|
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
|
||||||
|
const val LINK_RA_SOURCE = "link.ra_source"
|
||||||
|
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
|
||||||
|
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
|
||||||
|
// icmp
|
||||||
|
const val ICMP_PING4 = "icmp.ping4"
|
||||||
|
const val ICMP_PING6 = "icmp.ping6"
|
||||||
|
// trace
|
||||||
|
const val TRACEROUTE_UDP4 = "traceroute.udp4"
|
||||||
|
const val TRACEROUTE_UDP6 = "traceroute.udp6"
|
||||||
|
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
|
||||||
|
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
|
||||||
|
// train
|
||||||
|
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
|
||||||
|
// mtu
|
||||||
|
const val MTU_PMTUD_UP = "mtu.pmtud_up"
|
||||||
|
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
|
||||||
|
const val MTU_BLACKHOLE = "mtu.blackhole"
|
||||||
|
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
|
||||||
|
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
|
||||||
|
// nat
|
||||||
|
const val NAT_STUN_5780 = "nat.stun_5780"
|
||||||
|
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
|
||||||
|
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
|
||||||
|
const val NAT_HAIRPIN = "nat.hairpin"
|
||||||
|
const val NAT_CONNECT_BACK = "nat.connect_back"
|
||||||
|
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
|
||||||
|
// dns
|
||||||
|
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
|
||||||
|
const val DNS_CANARY = "dns.canary"
|
||||||
|
const val DNS_INTERCEPTION = "dns.interception"
|
||||||
|
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
|
||||||
|
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
|
||||||
|
const val DNS_DNSSEC = "dns.dnssec"
|
||||||
|
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
|
||||||
|
const val DNS_REBIND_FILTER = "dns.rebind_filter"
|
||||||
|
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
|
||||||
|
const val DNS_DNS64 = "dns.dns64"
|
||||||
|
const val DNS_COMPARE = "dns.compare"
|
||||||
|
// sec
|
||||||
|
const val SEC_TLS_REFERENCE = "sec.tls_reference"
|
||||||
|
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
|
||||||
|
const val SEC_HTTP_ECHO = "sec.http_echo"
|
||||||
|
const val SEC_SNI_FILTER = "sec.sni_filter"
|
||||||
|
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
|
||||||
|
const val SEC_ARP_WATCH = "sec.arp_watch"
|
||||||
|
// port
|
||||||
|
const val PORT_REACH_SWEEP = "port.reach_sweep"
|
||||||
|
const val PORT_UDP_USABILITY = "port.udp_usability"
|
||||||
|
// perf
|
||||||
|
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
|
||||||
|
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
|
||||||
|
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
|
||||||
|
const val PERF_RRC_LATENCY = "perf.rrc_latency"
|
||||||
|
// v6
|
||||||
|
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
|
||||||
|
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
|
||||||
|
const val V6_BROKENNESS = "v6.brokenness"
|
||||||
|
const val V6_NAT64_CLAT = "v6.nat64_clat"
|
||||||
|
// wifi
|
||||||
|
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
|
||||||
|
const val WIFI_ROAM_LOG = "wifi.roam_log"
|
||||||
|
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
|
||||||
|
// local
|
||||||
|
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
|
||||||
|
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
|
||||||
|
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
|
||||||
|
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
|
||||||
|
const val LOCAL_NTP = "local.ntp"
|
||||||
|
// peer
|
||||||
|
const val PEER_REACHABILITY = "peer.reachability"
|
||||||
|
const val PEER_ISOLATION = "peer.isolation"
|
||||||
|
const val PEER_MULTICAST = "peer.multicast"
|
||||||
|
const val PEER_LAN_TRAIN = "peer.lan_train"
|
||||||
|
const val PEER_LEASE_DIFF = "peer.lease_diff"
|
||||||
|
// time
|
||||||
|
const val TIME_SERVER_OFFSET = "time.server_offset"
|
||||||
|
|
||||||
|
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
|
||||||
|
fun category(type: String): Category = when (type.substringBefore('.')) {
|
||||||
|
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
|
||||||
|
"dns" -> Category.DNS
|
||||||
|
"nat" -> Category.NAT
|
||||||
|
"mtu" -> Category.MTU
|
||||||
|
"v6" -> Category.IPV6
|
||||||
|
"sec" -> Category.SECURITY
|
||||||
|
"perf" -> Category.PERFORMANCE
|
||||||
|
"local", "peer" -> Category.LOCAL
|
||||||
|
"wifi" -> Category.WIFI
|
||||||
|
else -> Category.CONNECTIVITY
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class SerializationTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun documentRoundTrips() {
|
||||||
|
val doc = MeasurementDocument(
|
||||||
|
run = Run(
|
||||||
|
id = "0198c5f2-0000-7000-8000-000000000000",
|
||||||
|
trigger = Trigger.MANUAL,
|
||||||
|
startedAt = "2026-07-31T14:03:21.114Z",
|
||||||
|
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
|
||||||
|
app = AppInfo(version = "0.1.0", build = 1),
|
||||||
|
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
|
||||||
|
tiers = Tiers(app = true, shizuku = true),
|
||||||
|
),
|
||||||
|
networks = listOf(
|
||||||
|
Network(
|
||||||
|
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
|
||||||
|
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
|
||||||
|
wifi = Wifi(ssid = "example", rssiDbm = -54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tests = listOf(
|
||||||
|
Test(
|
||||||
|
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
|
||||||
|
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
|
||||||
|
evidence = TrainEvidence(
|
||||||
|
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
|
||||||
|
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
|
||||||
|
).toEvidence(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
|
||||||
|
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
|
||||||
|
assertEquals(doc.run.id, decoded.run.id)
|
||||||
|
assertEquals(Transport.WIFI, decoded.networks[0].transport)
|
||||||
|
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
|
||||||
|
// snake_case field names on the wire
|
||||||
|
assertTrue(encoded.contains("\"schema_version\""))
|
||||||
|
assertTrue(encoded.contains("\"mono_origin_wall\""))
|
||||||
|
assertTrue(encoded.contains("\"t_tx_ns\""))
|
||||||
|
// null preserved at train index 1
|
||||||
|
assertTrue(encoded.contains("[16500000,null]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun findingRequiresEvidence() {
|
||||||
|
try {
|
||||||
|
Finding(
|
||||||
|
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
|
||||||
|
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
|
||||||
|
)
|
||||||
|
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
|
||||||
|
} catch (e: IllegalArgumentException) {
|
||||||
|
// expected — a finding with no evidence is invalid (§7.1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.measurement
|
||||||
|
|
||||||
|
import kotlin.test.Test as JTest
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
class VerdictsTest {
|
||||||
|
|
||||||
|
private fun test(type: String, status: TestStatus, id: String = type): Test =
|
||||||
|
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
|
||||||
|
|
||||||
|
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
|
||||||
|
Finding(
|
||||||
|
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
|
||||||
|
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
|
||||||
|
)
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryLightFromWorstSeverity() {
|
||||||
|
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.LOW),
|
||||||
|
finding(Category.DNS, Severity.HIGH), // worst → red
|
||||||
|
finding(Category.DNS, Severity.INFO),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun noFindingsIsGreen() {
|
||||||
|
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun mediumAndLowAreYellow() {
|
||||||
|
val s = Verdicts.derive(
|
||||||
|
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
|
||||||
|
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
|
||||||
|
)
|
||||||
|
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun majorityFailedIsInconclusive() {
|
||||||
|
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
|
||||||
|
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(2, s.categories["dns"]!!.testsFailed)
|
||||||
|
assertEquals(3, s.categories["dns"]!!.testsRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun exactlyHalfFailedIsNotInconclusive() {
|
||||||
|
// 1 of 2 failed → not > 50% → the finding decides.
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
|
||||||
|
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
|
||||||
|
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallIsWorstCategory() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
|
||||||
|
)
|
||||||
|
val findings = listOf(
|
||||||
|
finding(Category.DNS, Severity.MEDIUM), // yellow
|
||||||
|
finding(Category.MTU, Severity.CRITICAL), // red
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, findings)
|
||||||
|
assertEquals(Verdict.RED, s.overall)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun overallInconclusiveOnlyWhenAllAre() {
|
||||||
|
val tests = listOf(
|
||||||
|
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
|
||||||
|
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
|
||||||
|
)
|
||||||
|
val s = Verdicts.derive(tests, emptyList())
|
||||||
|
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
|
||||||
|
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
|
||||||
|
}
|
||||||
|
|
||||||
|
@JTest
|
||||||
|
fun categoryMappingCoversFamilies() {
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
|
||||||
|
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
|
||||||
|
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
|
||||||
|
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
|
||||||
|
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 has built-in Kotlin — do NOT also apply kotlin.android (double-registers
|
||||||
|
// the `kotlin` extension). Only the serialization compiler plugin is added.
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-tier probes (Android platform APIs), emitting core-measurement Test
|
||||||
|
// objects. Ported/adapted from the validated echolot-prober. minSdk 26 to
|
||||||
|
// match the prober and the feasibility findings.
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.probe"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Network
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reproduces Android's own "do I have internet / is there a captive portal?" logic
|
||||||
|
* (NetworkMonitor): it fetches `generate_204` endpoints and checks for **HTTP 204 No Content**.
|
||||||
|
*
|
||||||
|
* Per active network (bound via Network.openConnection):
|
||||||
|
* - **HTTPS 204** (`https://www.google.com/generate_204`) → real validated internet.
|
||||||
|
* - **HTTP 204** (`http://connectivitycheck.gstatic.com/generate_204`) → a plain-HTTP path with
|
||||||
|
* no interference. A 3xx redirect or a 200-with-body instead of 204 is the classic **captive
|
||||||
|
* portal** signature (the portal's login page); the redirect Location is captured.
|
||||||
|
* - timeout/IO error on both → no working internet on that network.
|
||||||
|
*
|
||||||
|
* These are the AOSP default probe URLs (Settings.Global CAPTIVE_PORTAL_HTTPS_URL /
|
||||||
|
* CAPTIVE_PORTAL_HTTP_URL). Redirects are NOT followed — an unfollowed 3xx is the evidence.
|
||||||
|
*/
|
||||||
|
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
|
override val type = TestType.NET_CAPTIVE_PORTAL
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 9_000L // two HTTP probes per network, 4s timeouts each
|
||||||
|
|
||||||
|
private val httpsUrl = "https://www.google.com/generate_204"
|
||||||
|
private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204"
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val perNet = LinkedHashMap<String, ProbeResult>()
|
||||||
|
|
||||||
|
// Default network first, then each active network explicitly.
|
||||||
|
perNet["default"] = validate(null)
|
||||||
|
for (e in entries) {
|
||||||
|
perNet["${e.model.transport.name.lowercase()}:${e.model.id}"] = validate(e.handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("https_url", httpsUrl); put("http_url", httpUrl)
|
||||||
|
for ((label, r) in perNet) putJsonObject(label) {
|
||||||
|
put("https_code", r.httpsCode); put("http_code", r.httpCode)
|
||||||
|
r.portalLocation?.let { put("portal_location", it) }
|
||||||
|
put("verdict", r.verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best verdict across networks: validated > portal > none.
|
||||||
|
val anyValidated = perNet.values.any { it.verdict == "validated" }
|
||||||
|
val anyPortal = perNet.values.any { it.verdict == "captive_portal" }
|
||||||
|
val status = when {
|
||||||
|
anyValidated -> TestStatus.OK
|
||||||
|
anyPortal -> TestStatus.PARTIAL // reachable but intercepted
|
||||||
|
else -> TestStatus.FAILED // no working internet anywhere
|
||||||
|
}
|
||||||
|
b.build(status, evidence = evidence)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ProbeResult(
|
||||||
|
val httpsCode: Int, val httpCode: Int, val portalLocation: String?, val verdict: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun validate(network: Network?): ProbeResult {
|
||||||
|
val https = probe(network, httpsUrl)
|
||||||
|
val http = probe(network, httpUrl)
|
||||||
|
val portalLoc = http.location.takeIf { http.code in 300..399 }
|
||||||
|
val verdict = when {
|
||||||
|
https.code == 204 -> "validated" // real internet
|
||||||
|
http.code == 204 -> "validated_http_only" // HTTP clean, HTTPS blocked
|
||||||
|
http.code in 300..399 || (http.code == 200 && http.hadBody) -> "captive_portal"
|
||||||
|
https.code < 0 && http.code < 0 -> "no_internet"
|
||||||
|
else -> "inconclusive"
|
||||||
|
}
|
||||||
|
return ProbeResult(https.code, http.code, portalLoc, verdict)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Resp(val code: Int, val location: String?, val hadBody: Boolean)
|
||||||
|
|
||||||
|
/** One probe: code (-1 on failure), Location header, and whether a body was present (204 has none). */
|
||||||
|
private fun probe(network: Network?, urlStr: String): Resp {
|
||||||
|
var conn: HttpURLConnection? = null
|
||||||
|
return try {
|
||||||
|
val url = URL(urlStr)
|
||||||
|
conn = (network?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
|
||||||
|
conn.instanceFollowRedirects = false // an unfollowed 3xx is the portal signal
|
||||||
|
conn.connectTimeout = 4000
|
||||||
|
conn.readTimeout = 4000
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.setRequestProperty("User-Agent", "Echolot")
|
||||||
|
conn.setRequestProperty("Connection", "close")
|
||||||
|
val code = conn.responseCode
|
||||||
|
val loc = conn.getHeaderField("Location")
|
||||||
|
val body = runCatching {
|
||||||
|
(conn.inputStream ?: conn.errorStream)?.use { it.read() != -1 }
|
||||||
|
}.getOrNull() ?: false
|
||||||
|
Resp(code, loc, body)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Resp(-1, null, false)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Resp(-1, null, false)
|
||||||
|
} finally {
|
||||||
|
conn?.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonObject
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* dns.canary / dns.answer_integrity — resolves the server's canary zone through the network's own
|
||||||
|
* resolver and compares against the spec-frozen ground truth (probe-protocol.md §6.1).
|
||||||
|
*
|
||||||
|
* Two things are checked, and they detect different failures:
|
||||||
|
* - **Reference records** (`ttl-5`, `many-rr`, …) have FIXED RDATA fixed by the spec, so a
|
||||||
|
* mismatch means the answer was rewritten in flight (interception/filtering).
|
||||||
|
* - A **per-run nonce name** `<nonce>.<session>.<zone>` can never have been cached, so it proves
|
||||||
|
* the query reached the authoritative server, and the answer is derived from the nonce itself.
|
||||||
|
*
|
||||||
|
* Resolution goes through the platform resolver (InetAddress), i.e. exactly the path apps use —
|
||||||
|
* so interception by the network's DNS is what we measure. The server side records who actually
|
||||||
|
* asked (its observation API), letting the app pair "what I got" with "who asked".
|
||||||
|
*/
|
||||||
|
class DnsCanaryProbe(
|
||||||
|
private val canaryZone: String,
|
||||||
|
private val sessionPrefix: String,
|
||||||
|
private val nonce: String = java.util.UUID.randomUUID().toString().take(8),
|
||||||
|
) : Probe {
|
||||||
|
override val type = TestType.DNS_CANARY
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 3_000L // five resolutions through the platform resolver
|
||||||
|
|
||||||
|
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
|
||||||
|
private val references = listOf(
|
||||||
|
Reference("ttl-5", "192.0.2.5"),
|
||||||
|
Reference("ttl-60", "192.0.2.60"),
|
||||||
|
Reference("ttl-3600", "192.0.2.36"),
|
||||||
|
Reference("ttl-86400", "192.0.2.86"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private data class Reference(val label: String, val expectedA: String)
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
if (canaryZone.isBlank()) {
|
||||||
|
return@withContext b.build(
|
||||||
|
TestStatus.SKIPPED,
|
||||||
|
evidence = buildJsonObject { put("reason", "no canary zone configured (needs a server profile)") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var matched = 0
|
||||||
|
var mismatched = 0
|
||||||
|
var failed = 0
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("zone", canaryZone)
|
||||||
|
putJsonObject("reference_records") {
|
||||||
|
for (r in references) {
|
||||||
|
val fqdn = "${r.label}.$canaryZone"
|
||||||
|
val got = resolveA(fqdn)
|
||||||
|
putJsonObject(r.label) {
|
||||||
|
put("fqdn", fqdn); put("expected", r.expectedA); put("got", got ?: "")
|
||||||
|
val verdict = when {
|
||||||
|
got == null -> { failed++; "resolve_failed" }
|
||||||
|
got == r.expectedA -> { matched++; "match" }
|
||||||
|
else -> { mismatched++; "MISMATCH (answer rewritten in flight)" }
|
||||||
|
}
|
||||||
|
put("verdict", verdict)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cache-miss proof: a nonce name that cannot have been pre-cached.
|
||||||
|
val nonceFqdn = "$nonce.$sessionPrefix.$canaryZone"
|
||||||
|
val nonceGot = resolveA(nonceFqdn)
|
||||||
|
putJsonObject("nonce_query") {
|
||||||
|
put("fqdn", nonceFqdn)
|
||||||
|
put("got", nonceGot ?: "")
|
||||||
|
// The server answers nonce names from 192.0.2.0/24 (deterministic per nonce).
|
||||||
|
val reached = nonceGot?.startsWith("192.0.2.") == true
|
||||||
|
put("reached_authoritative", reached)
|
||||||
|
put("note", "a non-192.0.2.x answer means something other than the canary server replied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val metrics = buildJsonObject {
|
||||||
|
put("references_matched", matched); put("references_mismatched", mismatched)
|
||||||
|
put("references_failed", failed)
|
||||||
|
}
|
||||||
|
val status = when {
|
||||||
|
mismatched > 0 -> TestStatus.PARTIAL // answers altered — a finding
|
||||||
|
matched == 0 -> TestStatus.FAILED // nothing resolved
|
||||||
|
failed > 0 -> TestStatus.PARTIAL
|
||||||
|
else -> TestStatus.OK
|
||||||
|
}
|
||||||
|
b.build(status, evidence = evidence, metrics = metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First IPv4 answer via the platform resolver (the path a normal app takes), or null. */
|
||||||
|
private fun resolveA(fqdn: String): String? = runCatching {
|
||||||
|
InetAddress.getAllByName(fqdn).firstOrNull { it is java.net.Inet4Address }?.hostAddress
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Network
|
||||||
|
import android.system.Os
|
||||||
|
import android.system.OsConstants
|
||||||
|
import android.system.StructTimeval
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
|
||||||
|
* (Network.bindSocket). Ported from the prober, which validated on real hardware that Android's
|
||||||
|
* open ping_group_range makes this work with no root — and that per-network binding turns a
|
||||||
|
* default-network v6 EAGAIN into topology evidence rather than a false failure.
|
||||||
|
*/
|
||||||
|
class IcmpProbe(
|
||||||
|
private val entries: List<NetworkInventory.Entry>,
|
||||||
|
private val v6: Boolean,
|
||||||
|
private val target: String = if (v6) "2606:4700:4700::1111" else "1.1.1.1",
|
||||||
|
) : Probe {
|
||||||
|
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
|
||||||
|
override val tier = Tier.APP
|
||||||
|
// v6 on a v4-only network waits out a 3s timeout per network; v4 answers in ms.
|
||||||
|
override val estimatedMs = if (v6) 7_000L else 800L
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val perNetwork = LinkedHashMap<String, String>()
|
||||||
|
var anyOk = false
|
||||||
|
val rtts = ArrayList<Double>()
|
||||||
|
|
||||||
|
// Default network first, then each active network explicitly.
|
||||||
|
attempt(null).let { (ok, detail, rtt) ->
|
||||||
|
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
|
}
|
||||||
|
for (e in entries) {
|
||||||
|
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
|
||||||
|
val (ok, detail, rtt) = attempt(e.handle)
|
||||||
|
perNetwork[label] = detail
|
||||||
|
if (ok) { anyOk = true; rtt?.let(rtts::add) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("target", target)
|
||||||
|
for ((k, v) in perNetwork) put(k, v)
|
||||||
|
}
|
||||||
|
val metrics: JsonObject = buildJsonObject {
|
||||||
|
put("networks_ok", rtts.size)
|
||||||
|
rtts.minOrNull()?.let { put("rtt_ms_min", round1(it)) }
|
||||||
|
if (rtts.isNotEmpty()) put("rtt_ms_avg", round1(rtts.average()))
|
||||||
|
rtts.maxOrNull()?.let { put("rtt_ms_max", round1(it)) }
|
||||||
|
}
|
||||||
|
val status = if (anyOk) TestStatus.OK else TestStatus.FAILED
|
||||||
|
b.build(status, evidence = evidence, metrics = metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Attempt(val ok: Boolean, val detail: String, val rttMs: Double?)
|
||||||
|
|
||||||
|
private fun attempt(network: Network?): Attempt {
|
||||||
|
var fd: FileDescriptor? = null
|
||||||
|
return try {
|
||||||
|
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
|
||||||
|
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
|
||||||
|
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
|
||||||
|
network?.bindSocket(fd)
|
||||||
|
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
|
||||||
|
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
|
||||||
|
|
||||||
|
val ident = (Os.getpid() and 0xFFFF)
|
||||||
|
val packet = buildEchoRequest(v6, ident.toShort(), 1)
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
|
||||||
|
val buf = ByteBuffer.allocate(1500)
|
||||||
|
val received = Os.recvfrom(fd, buf, 0, null)
|
||||||
|
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||||
|
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
|
||||||
|
val ok = replyType == (if (v6) 129 else 0)
|
||||||
|
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}", null)
|
||||||
|
} finally {
|
||||||
|
fd?.let { runCatching { Os.close(it) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
|
||||||
|
val type = if (v6) 128 else 8
|
||||||
|
val payload = "echolot".toByteArray()
|
||||||
|
val pkt = ByteBuffer.allocate(8 + payload.size)
|
||||||
|
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
|
||||||
|
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
|
||||||
|
val bytes = pkt.array()
|
||||||
|
if (!v6) {
|
||||||
|
val cs = checksum(bytes)
|
||||||
|
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checksum(b: ByteArray): Short {
|
||||||
|
var sum = 0; var i = 0
|
||||||
|
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
|
||||||
|
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
|
||||||
|
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
|
||||||
|
return sum.inv().toShort()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonArray
|
||||||
|
import kotlinx.serialization.json.addJsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* link.snapshot: records every active network's LinkProperties as evidence. The full network
|
||||||
|
* models also feed the document's `networks[]` (see [NetworkInventory]); this test captures the
|
||||||
|
* count and a compact per-network summary so the snapshot is attributable in `tests[]`.
|
||||||
|
*/
|
||||||
|
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
|
override val type = TestType.LINK_SNAPSHOT
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 200L // reads LinkProperties, no I/O
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("network_count", entries.size)
|
||||||
|
putJsonArray("networks") {
|
||||||
|
for (e in entries) addJsonObject {
|
||||||
|
put("id", e.model.id)
|
||||||
|
put("transport", e.model.transport.name.lowercase())
|
||||||
|
put("interface", e.model.iface ?: "")
|
||||||
|
put("mtu", e.model.link.mtu ?: 0)
|
||||||
|
put("addresses", e.model.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" })
|
||||||
|
put("dns", (e.model.link.dns?.servers ?: emptyList()).joinToString(", "))
|
||||||
|
put("nat64", e.model.link.dns?.nat64Prefix ?: "none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
|
||||||
|
return b.build(status, evidence = evidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.LinkProperties
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import app.echo_lot.measurement.Address
|
||||||
|
import app.echo_lot.measurement.DnsConfig
|
||||||
|
import app.echo_lot.measurement.Link
|
||||||
|
import app.echo_lot.measurement.Route
|
||||||
|
import app.echo_lot.measurement.Transport
|
||||||
|
import app.echo_lot.measurement.Network as MNetwork
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the app-tier snapshot of every active Android Network into measurement `networks[]`
|
||||||
|
* (measurement-schema.md §4). App tier fills what LinkProperties exposes; route proto and address
|
||||||
|
* lifetimes are Shizuku-tier and left absent (absence = "not observed"). Ported from the prober's
|
||||||
|
* LinkPropertiesProbe.
|
||||||
|
*/
|
||||||
|
object NetworkInventory {
|
||||||
|
|
||||||
|
/** One measurement Network per active Android Network, plus the Android Network handle so
|
||||||
|
* server/ICMP probes can bind to it. */
|
||||||
|
data class Entry(val model: MNetwork, val handle: android.net.Network)
|
||||||
|
|
||||||
|
fun snapshot(ctx: Context): List<Entry> {
|
||||||
|
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val out = ArrayList<Entry>()
|
||||||
|
var idx = 0
|
||||||
|
for (net in cm.allNetworks) {
|
||||||
|
val caps = cm.getNetworkCapabilities(net) ?: continue
|
||||||
|
val lp = cm.getLinkProperties(net) ?: continue
|
||||||
|
out.add(Entry(model = toModel("net-${idx}", caps, lp), handle = net))
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
|
||||||
|
val transport = when {
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Transport.CELLULAR
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Transport.ETHERNET
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> Transport.VPN
|
||||||
|
else -> Transport.OTHER
|
||||||
|
}
|
||||||
|
val addresses = lp.linkAddresses.map {
|
||||||
|
Address(
|
||||||
|
addr = it.address.hostAddress ?: it.address.toString(),
|
||||||
|
prefixLen = it.prefixLength,
|
||||||
|
scope = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val routes = lp.routes.map {
|
||||||
|
Route(
|
||||||
|
dst = it.destination.toString(),
|
||||||
|
gateway = it.gateway?.hostAddress,
|
||||||
|
iface = it.`interface`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val nat64 = runCatching { lp.nat64Prefix?.toString() }.getOrNull()
|
||||||
|
val dns = DnsConfig(
|
||||||
|
servers = lp.dnsServers.mapNotNull { it.hostAddress },
|
||||||
|
privateDnsMode = if (lp.isPrivateDnsActive) "strict/opportunistic" else "off",
|
||||||
|
privateDnsHostname = lp.privateDnsServerName,
|
||||||
|
searchDomains = lp.domains?.split(",")?.map { it.trim() } ?: emptyList(),
|
||||||
|
nat64Prefix = nat64,
|
||||||
|
)
|
||||||
|
return MNetwork(
|
||||||
|
id = id, transport = transport, iface = lp.interfaceName,
|
||||||
|
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal OUI → vendor lookup for identifying gateways/routers from a MAC address.
|
||||||
|
*
|
||||||
|
* Deliberately a small curated table rather than the full IEEE registry (~35k entries, ~1.5 MB):
|
||||||
|
* the goal is naming the box that routes a home/office LAN, and consumer/SOHO gear concentrates
|
||||||
|
* in a handful of vendors. An unknown OUI is reported verbatim so it is never silently wrong —
|
||||||
|
* and the SSDP/UPnP identity in [RouterIdentityProbe] usually names the exact model anyway.
|
||||||
|
*/
|
||||||
|
object Oui {
|
||||||
|
|
||||||
|
private val table: Map<String, String> = mapOf(
|
||||||
|
// AVM (FRITZ!Box) — dominant in DE/AT
|
||||||
|
"00:04:0E" to "AVM", "38:10:D5" to "AVM", "5C:49:79" to "AVM", "C8:0E:14" to "AVM",
|
||||||
|
"3C:A6:2F" to "AVM", "9C:C7:A6" to "AVM", "E0:28:6D" to "AVM", "24:65:11" to "AVM",
|
||||||
|
// Ubiquiti
|
||||||
|
"00:15:6D" to "Ubiquiti", "04:18:D6" to "Ubiquiti", "24:5A:4C" to "Ubiquiti",
|
||||||
|
"44:D9:E7" to "Ubiquiti", "68:72:51" to "Ubiquiti", "78:8A:20" to "Ubiquiti",
|
||||||
|
"74:AC:B9" to "Ubiquiti", "F0:9F:C2" to "Ubiquiti", "B4:FB:E4" to "Ubiquiti",
|
||||||
|
"E0:63:DA" to "Ubiquiti", "78:45:58" to "Ubiquiti", "AC:8B:A9" to "Ubiquiti",
|
||||||
|
// MikroTik
|
||||||
|
"00:0C:42" to "MikroTik", "4C:5E:0C" to "MikroTik", "6C:3B:6B" to "MikroTik",
|
||||||
|
"48:8F:5A" to "MikroTik", "2C:C8:1B" to "MikroTik", "DC:2C:6E" to "MikroTik",
|
||||||
|
"78:9A:18" to "MikroTik", "18:FD:74" to "MikroTik", "64:D1:54" to "MikroTik",
|
||||||
|
"74:4D:28" to "MikroTik", "C4:AD:34" to "MikroTik", "E4:8D:8C" to "MikroTik",
|
||||||
|
// TP-Link
|
||||||
|
"00:1D:0F" to "TP-Link", "14:CC:20" to "TP-Link", "50:C7:BF" to "TP-Link",
|
||||||
|
"A4:2B:B0" to "TP-Link", "C0:06:C3" to "TP-Link", "EC:08:6B" to "TP-Link",
|
||||||
|
// Netgear
|
||||||
|
"00:09:5B" to "Netgear", "20:4E:7F" to "Netgear", "A0:40:A0" to "Netgear",
|
||||||
|
"C4:04:15" to "Netgear", "9C:3D:CF" to "Netgear",
|
||||||
|
// ASUS
|
||||||
|
"00:1B:FC" to "ASUS", "2C:56:DC" to "ASUS", "50:46:5D" to "ASUS", "AC:9E:17" to "ASUS",
|
||||||
|
"04:D9:F5" to "ASUS", "1C:B7:2C" to "ASUS",
|
||||||
|
// Cisco / Meraki
|
||||||
|
"00:1A:2F" to "Cisco", "00:26:99" to "Cisco", "E0:CB:BC" to "Cisco",
|
||||||
|
"00:18:0A" to "Cisco Meraki", "88:15:44" to "Cisco Meraki", "E0:55:3D" to "Cisco Meraki",
|
||||||
|
// Zyxel / Draytek / Huawei / ZTE
|
||||||
|
"00:13:49" to "Zyxel", "5C:F4:AB" to "Zyxel", "00:1D:AA" to "DrayTek",
|
||||||
|
"00:E0:FC" to "Huawei", "48:46:FB" to "Huawei", "00:1E:73" to "ZTE",
|
||||||
|
// AVM-adjacent ISP CPE / others common on consumer LANs
|
||||||
|
"00:17:3F" to "Belkin", "B8:27:EB" to "Raspberry Pi", "DC:A6:32" to "Raspberry Pi",
|
||||||
|
"E4:5F:01" to "Raspberry Pi", "00:50:56" to "VMware", "52:54:00" to "QEMU/KVM",
|
||||||
|
"18:E8:29" to "Ubiquiti", "70:A7:41" to "Ubiquiti",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Vendor for a MAC, or null when the OUI isn't in the curated table. */
|
||||||
|
fun vendor(mac: String): String? {
|
||||||
|
val norm = mac.uppercase().replace('-', ':').trim()
|
||||||
|
if (norm.length < 8) return null
|
||||||
|
return table[norm.substring(0, 8)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for a locally-administered (often randomized) MAC — not a real vendor identity. */
|
||||||
|
fun isLocallyAdministered(mac: String): Boolean {
|
||||||
|
val first = mac.replace('-', ':').split(':').firstOrNull() ?: return false
|
||||||
|
val b = first.toIntOrNull(16) ?: return false
|
||||||
|
return (b and 0x02) != 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
// Package probe holds the device-tier probes (app + shizuku tiers). Each probe runs a platform
|
||||||
|
// measurement and returns a core-measurement [Test] — raw evidence + recomputable metrics — never
|
||||||
|
// throwing to the caller. Ported from the validated echolot-prober, now emitting the production
|
||||||
|
// schema instead of the prober's ad-hoc format.
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestError
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
|
||||||
|
/** A single device-tier measurement. [type] is a TestType registry id. */
|
||||||
|
interface Probe {
|
||||||
|
val type: String
|
||||||
|
val tier: Tier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rough wall-clock estimate in ms, used only to drive the progress bar / ETA. Probes that
|
||||||
|
* wait on timeouts (ICMPv6 on a v4-only net, SSDP, STUN) dominate a run, so they override
|
||||||
|
* this with realistic values measured on device — a bad estimate misleads the user, it does
|
||||||
|
* not break the run.
|
||||||
|
*/
|
||||||
|
val estimatedMs: Long get() = 2_000
|
||||||
|
|
||||||
|
/** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so
|
||||||
|
* results are attributable and use the two-clock rule. Must never throw. */
|
||||||
|
suspend fun run(ctx: Context, ids: ProbeIds): Test
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injected UUID/clock source (measurement-schema.md: UUIDv7 ids, *_mono_ns math clock). */
|
||||||
|
interface ProbeIds {
|
||||||
|
fun uuid(): String
|
||||||
|
/** Monotonic nanoseconds relative to the run's mono origin. */
|
||||||
|
fun monoNs(): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a [Test] envelope, capturing start/end from the shared clock. */
|
||||||
|
class TestBuilder(
|
||||||
|
private val type: String,
|
||||||
|
private val tier: Tier,
|
||||||
|
private val ids: ProbeIds,
|
||||||
|
private val networkRef: String? = null,
|
||||||
|
private val sessionRef: String? = null,
|
||||||
|
) {
|
||||||
|
private val id = ids.uuid()
|
||||||
|
private val startedMonoNs = ids.monoNs()
|
||||||
|
|
||||||
|
fun build(
|
||||||
|
status: TestStatus,
|
||||||
|
evidence: JsonObject? = null,
|
||||||
|
metrics: JsonObject? = null,
|
||||||
|
error: TestError? = null,
|
||||||
|
): Test = Test(
|
||||||
|
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
|
||||||
|
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
|
||||||
|
status = status, error = error, evidence = evidence, metrics = metrics,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import kotlinx.serialization.json.putJsonArray
|
||||||
|
import kotlinx.serialization.json.addJsonObject
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* link.ra_source — identifies **who is advertising IPv6 on this network** (and the IPv4 gateway),
|
||||||
|
* with as much attribution as an unprivileged app can gather.
|
||||||
|
*
|
||||||
|
* Why it matters: a rogue or misconfigured RA sender is one of the most common causes of broken
|
||||||
|
* IPv6, and "some router advertises a default route" is useless without knowing *which box*.
|
||||||
|
*
|
||||||
|
* Identification chain, best-effort and each step recorded as evidence:
|
||||||
|
* 1. **RA source** — the next-hop of the `::/0` route (a link-local `fe80::` address) per network.
|
||||||
|
* 2. **MAC from EUI-64** — a link-local formed the classic way encodes the sender's MAC
|
||||||
|
* (`fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`): strip `ff:fe` from the middle and flip
|
||||||
|
* the U/L bit. Privacy/stable-private addresses (RFC 7217) don't encode it — reported as such
|
||||||
|
* rather than guessed.
|
||||||
|
* 3. **Vendor** — OUI lookup on that MAC ([Oui]).
|
||||||
|
* 4. **UPnP/SSDP** — an M-SEARCH usually gets the router itself to answer with a `SERVER:` banner
|
||||||
|
* and a device-description URL; fetching it yields manufacturer / model / friendly name. This
|
||||||
|
* is what usually pins down the exact box.
|
||||||
|
* 5. **Reverse DNS** for the gateway addresses.
|
||||||
|
*
|
||||||
|
* Future cross-matching (same MAC seen via LLDP or in an SSDP/mDNS inventory) is why the MAC is
|
||||||
|
* always reported alongside every identity source.
|
||||||
|
*/
|
||||||
|
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
|
||||||
|
override val type = TestType.LINK_RA_SOURCE
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 7_000L // SSDP M-SEARCH window + description fetches + rDNS
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
val ssdp = ssdpDiscover() // ip -> (server banner, description url)
|
||||||
|
var raSenders = 0
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
putJsonArray("networks") {
|
||||||
|
for (e in entries) {
|
||||||
|
val n = e.model
|
||||||
|
val v6Gw = n.link.routes.firstOrNull { it.dst == "::/0" }?.gateway
|
||||||
|
val v4Gw = n.link.routes.firstOrNull { it.dst == "0.0.0.0/0" }?.gateway
|
||||||
|
addJsonObject {
|
||||||
|
put("network", "${n.transport.name.lowercase()}:${n.id}")
|
||||||
|
put("interface", n.iface ?: "")
|
||||||
|
|
||||||
|
// --- IPv6 RA sender ---
|
||||||
|
put("ra_source", v6Gw ?: "(none — no IPv6 default route)")
|
||||||
|
if (v6Gw != null) {
|
||||||
|
raSenders++
|
||||||
|
val mac = macFromEui64LinkLocal(v6Gw)
|
||||||
|
put("ra_source_mac", mac ?: "(not EUI-64 — privacy/RFC 7217 address)")
|
||||||
|
if (mac != null) {
|
||||||
|
put("ra_source_vendor", Oui.vendor(mac) ?: "unknown OUI ${mac.take(8)}")
|
||||||
|
put("ra_source_mac_locally_administered", Oui.isLocallyAdministered(mac))
|
||||||
|
}
|
||||||
|
put("ra_source_reverse_dns", reverseDns(v6Gw))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- IPv4 gateway (usually the same box) ---
|
||||||
|
put("v4_gateway", v4Gw ?: "(none)")
|
||||||
|
if (v4Gw != null) {
|
||||||
|
put("v4_gateway_reverse_dns", reverseDns(v4Gw))
|
||||||
|
val gwSsdp = ssdp[v4Gw]
|
||||||
|
if (gwSsdp != null) {
|
||||||
|
put("upnp_server", gwSsdp.server)
|
||||||
|
put("upnp_location", gwSsdp.location)
|
||||||
|
gwSsdp.details?.let { d ->
|
||||||
|
put("upnp_manufacturer", d.manufacturer)
|
||||||
|
put("upnp_model", d.model)
|
||||||
|
put("upnp_friendly_name", d.friendlyName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
put("upnp", "no UPnP/SSDP response from the gateway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every SSDP responder, so a rogue RA sender that is not the gateway can still be
|
||||||
|
// matched later (by IP now, by MAC once LLDP/mDNS inventories land).
|
||||||
|
putJsonArray("ssdp_responders") {
|
||||||
|
for ((ip, s) in ssdp) addJsonObject {
|
||||||
|
put("ip", ip); put("server", s.server); put("location", s.location)
|
||||||
|
s.details?.let {
|
||||||
|
put("manufacturer", it.manufacturer); put("model", it.model)
|
||||||
|
put("friendly_name", it.friendlyName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val metrics = buildJsonObject {
|
||||||
|
put("ra_senders", raSenders)
|
||||||
|
put("ssdp_responders", ssdp.size)
|
||||||
|
}
|
||||||
|
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
|
||||||
|
b.build(status, evidence = evidence, metrics = metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers the sender MAC from a modified-EUI-64 link-local address. The middle `ff:fe` marker
|
||||||
|
* must be present, and bit 1 of the first byte (U/L) is inverted back.
|
||||||
|
*/
|
||||||
|
private fun macFromEui64LinkLocal(addr: String): String? {
|
||||||
|
val bytes = runCatching { InetAddress.getByName(addr.substringBefore('%')).address }.getOrNull()
|
||||||
|
?: return null
|
||||||
|
if (bytes.size != 16) return null
|
||||||
|
// fe80::/10 with EUI-64: bytes 11,12 are 0xFF,0xFE
|
||||||
|
if ((bytes[11].toInt() and 0xFF) != 0xFF || (bytes[12].toInt() and 0xFF) != 0xFE) return null
|
||||||
|
val mac = byteArrayOf(
|
||||||
|
(bytes[8].toInt() xor 0x02).toByte(), bytes[9], bytes[10],
|
||||||
|
bytes[13], bytes[14], bytes[15],
|
||||||
|
)
|
||||||
|
return mac.joinToString(":") { "%02X".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reverseDns(ip: String): String = runCatching {
|
||||||
|
val clean = ip.substringBefore('%')
|
||||||
|
val host = InetAddress.getByName(clean).canonicalHostName
|
||||||
|
if (host == clean) "(none)" else host
|
||||||
|
}.getOrDefault("(none)")
|
||||||
|
|
||||||
|
private data class Ssdp(val server: String, val location: String, val details: Upnp?)
|
||||||
|
private data class Upnp(val manufacturer: String, val model: String, val friendlyName: String)
|
||||||
|
|
||||||
|
/** SSDP M-SEARCH for the InternetGatewayDevice + root devices; returns responder IP -> identity. */
|
||||||
|
private fun ssdpDiscover(): Map<String, Ssdp> {
|
||||||
|
val out = LinkedHashMap<String, Ssdp>()
|
||||||
|
val targets = listOf("urn:schemas-upnp-org:device:InternetGatewayDevice:1", "upnp:rootdevice")
|
||||||
|
runCatching {
|
||||||
|
DatagramSocket().use { sock ->
|
||||||
|
sock.soTimeout = 2500
|
||||||
|
sock.broadcast = true
|
||||||
|
for (st in targets) {
|
||||||
|
val msg = ("M-SEARCH * HTTP/1.1\r\n" +
|
||||||
|
"HOST: 239.255.255.250:1900\r\n" +
|
||||||
|
"MAN: \"ssdp:discover\"\r\n" +
|
||||||
|
"MX: 2\r\nST: $st\r\n\r\n").toByteArray()
|
||||||
|
sock.send(
|
||||||
|
DatagramPacket(msg, msg.size, InetSocketAddress("239.255.255.250", 1900))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val deadline = System.currentTimeMillis() + 3000
|
||||||
|
while (System.currentTimeMillis() < deadline) {
|
||||||
|
val buf = ByteArray(2048)
|
||||||
|
val dp = DatagramPacket(buf, buf.size)
|
||||||
|
try {
|
||||||
|
sock.receive(dp)
|
||||||
|
} catch (e: java.net.SocketTimeoutException) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val ip = dp.address?.hostAddress ?: continue
|
||||||
|
if (out.containsKey(ip)) continue
|
||||||
|
val text = String(buf, 0, dp.length)
|
||||||
|
val server = header(text, "SERVER") ?: ""
|
||||||
|
val location = header(text, "LOCATION") ?: ""
|
||||||
|
out[ip] = Ssdp(server, location, location.takeIf { it.isNotBlank() }?.let(::fetchUpnp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun header(msg: String, name: String): String? =
|
||||||
|
msg.lineSequence().firstOrNull { it.startsWith("$name:", ignoreCase = true) }
|
||||||
|
?.substringAfter(':')?.trim()
|
||||||
|
|
||||||
|
/** Fetches the UPnP device description and pulls the identifying fields. */
|
||||||
|
private fun fetchUpnp(location: String): Upnp? = runCatching {
|
||||||
|
val conn = (URL(location).openConnection() as HttpURLConnection).apply {
|
||||||
|
connectTimeout = 2500; readTimeout = 2500; requestMethod = "GET"
|
||||||
|
}
|
||||||
|
val xml = conn.inputStream.bufferedReader().use { it.readText().take(20_000) }
|
||||||
|
conn.disconnect()
|
||||||
|
Upnp(
|
||||||
|
manufacturer = tag(xml, "manufacturer"),
|
||||||
|
model = listOf(tag(xml, "modelName"), tag(xml, "modelNumber"))
|
||||||
|
.filter { it.isNotBlank() }.joinToString(" "),
|
||||||
|
friendlyName = tag(xml, "friendlyName"),
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun tag(xml: String, name: String): String =
|
||||||
|
Regex("<$name>(.*?)</$name>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(xml)?.groupValues?.get(1)?.trim() ?: ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.probe
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import app.echo_lot.measurement.Test
|
||||||
|
import app.echo_lot.measurement.TestStatus
|
||||||
|
import app.echo_lot.measurement.TestType
|
||||||
|
import app.echo_lot.measurement.Tier
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.security.SecureRandom
|
||||||
|
|
||||||
|
/**
|
||||||
|
* nat.stun_5780 — discovers this network's NAT behavior with plain RFC 5389/5780 STUN against the
|
||||||
|
* Echolot server (which advertises `stun-5780` when it has ≥2 same-family addresses).
|
||||||
|
*
|
||||||
|
* Three binding requests, and the comparison between them is the measurement:
|
||||||
|
* 1. **primary address, primary port** → the reflexive (public) address and port.
|
||||||
|
* 2. **same socket, alternate address** (the server's OTHER-ADDRESS): if the mapped port is
|
||||||
|
* unchanged, the NAT keeps one mapping regardless of destination → **endpoint-independent
|
||||||
|
* mapping** (good: peer-to-peer works). A different port → address/port-dependent mapping
|
||||||
|
* (symmetric NAT: P2P needs relays).
|
||||||
|
* 3. **CHANGE-REQUEST(change-port)** — asks the server to answer from a different port. A reply
|
||||||
|
* means the NAT/firewall accepts inbound from an endpoint it never sent to →
|
||||||
|
* **endpoint-independent filtering**; silence means address/port-dependent filtering.
|
||||||
|
*
|
||||||
|
* Also detects being behind NAT at all (mapped address ≠ local address) — the CGNAT/double-NAT
|
||||||
|
* signal when combined with the local address being private.
|
||||||
|
*/
|
||||||
|
class StunProbe(
|
||||||
|
private val serverHost: String,
|
||||||
|
private val stunPort: Int = 3478,
|
||||||
|
) : Probe {
|
||||||
|
override val type = TestType.NAT_STUN_5780
|
||||||
|
override val tier = Tier.APP
|
||||||
|
override val estimatedMs = 6_000L // three binding requests; the change-request usually times out (3s)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAGIC_COOKIE = 0x2112A442.toInt()
|
||||||
|
const val TYPE_BINDING_REQUEST = 0x0001
|
||||||
|
const val TYPE_BINDING_SUCCESS = 0x0101
|
||||||
|
const val ATTR_CHANGE_REQUEST = 0x0003
|
||||||
|
const val ATTR_XOR_MAPPED = 0x0020
|
||||||
|
const val ATTR_OTHER_ADDRESS = 0x802C
|
||||||
|
const val CHANGE_PORT = 0x02
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Mapped(val addr: String, val port: Int)
|
||||||
|
private data class Reply(val mapped: Mapped?, val other: Mapped?)
|
||||||
|
|
||||||
|
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
|
||||||
|
val b = TestBuilder(type, tier, ids)
|
||||||
|
DatagramSocket().use { sock ->
|
||||||
|
sock.soTimeout = 3000
|
||||||
|
val localPort = sock.localPort
|
||||||
|
|
||||||
|
// 1) primary
|
||||||
|
val r1 = request(sock, serverHost, stunPort, change = 0)
|
||||||
|
if (r1?.mapped == null) {
|
||||||
|
return@withContext b.build(
|
||||||
|
TestStatus.FAILED,
|
||||||
|
evidence = buildJsonObject {
|
||||||
|
put("server", "$serverHost:$stunPort")
|
||||||
|
put("error", "no STUN binding response (blocked or server unreachable)")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) same socket → the server's alternate address (OTHER-ADDRESS)
|
||||||
|
val alt = r1.other
|
||||||
|
val r2 = alt?.let { request(sock, it.addr, stunPort, change = 0) }
|
||||||
|
|
||||||
|
// 3) CHANGE-REQUEST(port) — tests inbound filtering
|
||||||
|
val r3 = request(sock, serverHost, stunPort, change = CHANGE_PORT)
|
||||||
|
|
||||||
|
val mappingBehavior = when {
|
||||||
|
alt == null -> "unknown (server has no OTHER-ADDRESS; only stun-basic)"
|
||||||
|
r2?.mapped == null -> "inconclusive (no reply from alternate address)"
|
||||||
|
r2.mapped.port == r1.mapped.port && r2.mapped.addr == r1.mapped.addr ->
|
||||||
|
"endpoint-independent (one mapping for all destinations — P2P friendly)"
|
||||||
|
else -> "address/port-dependent (symmetric NAT — P2P needs a relay)"
|
||||||
|
}
|
||||||
|
val filteringBehavior = when {
|
||||||
|
r3?.mapped != null -> "endpoint-independent (accepts inbound from an unseen endpoint)"
|
||||||
|
else -> "address/port-dependent (drops inbound from endpoints not contacted)"
|
||||||
|
}
|
||||||
|
// Behind NAT iff the reflexive address differs from this socket's local address.
|
||||||
|
// Port preservation is common and must NOT be read as "no NAT" — compare addresses.
|
||||||
|
val localAddr = localAddressOf(sock)
|
||||||
|
val behindNat = localAddr.isNotEmpty() && localAddr != r1.mapped.addr
|
||||||
|
|
||||||
|
val evidence: JsonObject = buildJsonObject {
|
||||||
|
put("server", "$serverHost:$stunPort")
|
||||||
|
put("local_addr", localAddr)
|
||||||
|
put("local_port", localPort)
|
||||||
|
put("mapped", "${r1.mapped.addr}:${r1.mapped.port}")
|
||||||
|
put("other_address", alt?.let { "${it.addr}:${it.port}" } ?: "")
|
||||||
|
put("mapped_via_alt", r2?.mapped?.let { "${it.addr}:${it.port}" } ?: "(no reply)")
|
||||||
|
put("change_port_reply", if (r3?.mapped != null) "received" else "none")
|
||||||
|
put("mapping_behavior", mappingBehavior)
|
||||||
|
put("filtering_behavior", filteringBehavior)
|
||||||
|
put("behind_nat", behindNat)
|
||||||
|
}
|
||||||
|
val metrics = buildJsonObject {
|
||||||
|
put("mapped_port", r1.mapped.port)
|
||||||
|
put("local_addr", localAddr)
|
||||||
|
put("local_port", localPort)
|
||||||
|
put("port_preserved", r1.mapped.port == localPort)
|
||||||
|
put("behind_nat", behindNat)
|
||||||
|
}
|
||||||
|
b.build(TestStatus.OK, evidence = evidence, metrics = metrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address this socket actually sources from. An unbound DatagramSocket reports the
|
||||||
|
* wildcard ("::"/"0.0.0.0"), which says nothing, so probe the route to the server with a
|
||||||
|
* throwaway connected socket and read its local address.
|
||||||
|
*/
|
||||||
|
private fun localAddressOf(sock: DatagramSocket): String {
|
||||||
|
val direct = sock.localAddress?.hostAddress ?: ""
|
||||||
|
if (direct.isNotEmpty() && direct != "::" && direct != "0.0.0.0") return direct
|
||||||
|
return runCatching {
|
||||||
|
DatagramSocket().use { s ->
|
||||||
|
s.connect(InetSocketAddress(serverHost, stunPort))
|
||||||
|
s.localAddress?.hostAddress ?: ""
|
||||||
|
}
|
||||||
|
}.getOrDefault("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One binding request; returns the parsed reply or null on timeout. */
|
||||||
|
private fun request(sock: DatagramSocket, host: String, port: Int, change: Int): Reply? {
|
||||||
|
val txid = ByteArray(12).also { SecureRandom().nextBytes(it) }
|
||||||
|
val attrs = if (change != 0) {
|
||||||
|
ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).apply {
|
||||||
|
putShort(ATTR_CHANGE_REQUEST.toShort()); putShort(4)
|
||||||
|
put(0); put(0); put(0); put(change.toByte())
|
||||||
|
}.array()
|
||||||
|
} else ByteArray(0)
|
||||||
|
|
||||||
|
val msg = ByteBuffer.allocate(20 + attrs.size).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
msg.putShort(TYPE_BINDING_REQUEST.toShort())
|
||||||
|
msg.putShort(attrs.size.toShort())
|
||||||
|
msg.putInt(MAGIC_COOKIE)
|
||||||
|
msg.put(txid)
|
||||||
|
msg.put(attrs)
|
||||||
|
val bytes = msg.array()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
sock.send(DatagramPacket(bytes, bytes.size, InetSocketAddress(host, port)))
|
||||||
|
val buf = ByteArray(1500)
|
||||||
|
val dp = DatagramPacket(buf, buf.size)
|
||||||
|
sock.receive(dp)
|
||||||
|
parse(buf, dp.length, txid)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(data: ByteArray, len: Int, txid: ByteArray): Reply? {
|
||||||
|
if (len < 20) return null
|
||||||
|
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
if ((bb.getShort(0).toInt() and 0xFFFF) != TYPE_BINDING_SUCCESS) return null
|
||||||
|
val msgLen = bb.getShort(2).toInt() and 0xFFFF
|
||||||
|
var mapped: Mapped? = null
|
||||||
|
var other: Mapped? = null
|
||||||
|
var off = 20
|
||||||
|
while (off + 4 <= 20 + msgLen && off + 4 <= len) {
|
||||||
|
val at = bb.getShort(off).toInt() and 0xFFFF
|
||||||
|
val al = bb.getShort(off + 2).toInt() and 0xFFFF
|
||||||
|
if (off + 4 + al > len) break
|
||||||
|
when (at) {
|
||||||
|
ATTR_XOR_MAPPED -> mapped = parseAddr(data, off + 4, al, txid, xor = true)
|
||||||
|
ATTR_OTHER_ADDRESS -> other = parseAddr(data, off + 4, al, txid, xor = false)
|
||||||
|
}
|
||||||
|
off += 4 + al + ((4 - al % 4) % 4)
|
||||||
|
}
|
||||||
|
return Reply(mapped, other)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 5389 address attribute; XOR-MAPPED needs de-XORing with the cookie + txid. */
|
||||||
|
private fun parseAddr(data: ByteArray, off: Int, len: Int, txid: ByteArray, xor: Boolean): Mapped? {
|
||||||
|
if (len < 8) return null
|
||||||
|
val v = data.copyOfRange(off, off + len)
|
||||||
|
val family = v[1].toInt() and 0xFF
|
||||||
|
var port = ((v[2].toInt() and 0xFF) shl 8) or (v[3].toInt() and 0xFF)
|
||||||
|
if (xor) port = port xor ((MAGIC_COOKIE ushr 16) and 0xFFFF)
|
||||||
|
val key = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
.putInt(MAGIC_COOKIE).put(txid).array()
|
||||||
|
return if (family == 0x01) {
|
||||||
|
val a = ByteArray(4) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
|
||||||
|
Mapped(a.joinToString(".") { (it.toInt() and 0xFF).toString() }, port)
|
||||||
|
} else {
|
||||||
|
if (len < 20) return null
|
||||||
|
val a = ByteArray(16) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
|
||||||
|
Mapped(java.net.InetAddress.getByAddress(a).hostAddress ?: "", port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.kotlin.jvm)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
|
||||||
|
// the Android app modules can depend on it and it stays unit-testable (incl.
|
||||||
|
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
|
||||||
|
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
// Build with the available JDK (Android Studio's JBR is 21) but emit
|
||||||
|
// Java-17 bytecode so the Android app modules can consume this library.
|
||||||
|
jvmToolchain(21)
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
// The live end-to-end test against a real server only runs when
|
||||||
|
// ECHOLOT_LIVE_URL is set; otherwise it self-skips (see LiveServerTest).
|
||||||
|
listOf("ECHOLOT_LIVE_URL", "ECHOLOT_LIVE_PIN", "ECHOLOT_LIVE_CRED",
|
||||||
|
"ECHOLOT_LIVE_UDP", "ECHOLOT_LIVE_TARGET").forEach { k ->
|
||||||
|
System.getenv(k)?.let { environment(k, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.URL
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
|
||||||
|
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
|
||||||
|
* java.net.http.HttpClient which needs API 34) with a pin-based SSLSocketFactory and hostname
|
||||||
|
* verification DISABLED: trust is the SPKI pin, never the certificate name (self-signed servers
|
||||||
|
* with no SAN are first-class). Blocking; the Android layer wraps calls in coroutines.
|
||||||
|
*
|
||||||
|
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
|
||||||
|
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
|
||||||
|
*/
|
||||||
|
class ControlClient(private val controlUrl: String, pins: Set<String>) {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val socketFactory = Pinning.sslContext(pins).socketFactory
|
||||||
|
|
||||||
|
private fun open(path: String, method: String, credential: String?): HttpsURLConnection {
|
||||||
|
val conn = URL(controlUrl.trimEnd('/') + path).openConnection() as HttpsURLConnection
|
||||||
|
conn.sslSocketFactory = socketFactory
|
||||||
|
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
|
||||||
|
conn.requestMethod = method
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 10_000
|
||||||
|
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun body(conn: HttpsURLConnection): String {
|
||||||
|
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
return stream?.bufferedReader()?.use { it.readText() } ?: ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeJson(conn: HttpsURLConnection, payload: String) {
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.outputStream.use { it.write(payload.toByteArray()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal JSON string literal (the only bodies we send are one short field).
|
||||||
|
private fun jstr(s: String): String {
|
||||||
|
val sb = StringBuilder("\"")
|
||||||
|
for (c in s) when (c) {
|
||||||
|
'"' -> sb.append("\\\"")
|
||||||
|
'\\' -> sb.append("\\\\")
|
||||||
|
'\n' -> sb.append("\\n")
|
||||||
|
'\r' -> sb.append("\\r")
|
||||||
|
'\t' -> sb.append("\\t")
|
||||||
|
else -> sb.append(c)
|
||||||
|
}
|
||||||
|
return sb.append('"').toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redeem a single-use enrollment token for a device credential (§2.1). */
|
||||||
|
fun enroll(token: String, name: String? = null): EnrollResponse {
|
||||||
|
val conn = open("/v1/enroll", "POST", null)
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer $token")
|
||||||
|
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
|
||||||
|
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
|
||||||
|
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun profile(credential: String): Profile {
|
||||||
|
val conn = open("/v1/profile", "GET", credential)
|
||||||
|
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
|
||||||
|
return json.decodeFromString(Profile.serializer(), body(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createSession(credential: String, target: String): SessionResponse {
|
||||||
|
val conn = open("/v1/sessions", "POST", credential)
|
||||||
|
writeJson(conn, """{"target":${jstr(target)}}""")
|
||||||
|
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
|
||||||
|
return json.decodeFromString(SessionResponse.serializer(), body(conn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
|
||||||
|
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
|
||||||
|
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
|
||||||
|
*/
|
||||||
|
fun action(credential: String, sessionId: String, bodyJson: String): String {
|
||||||
|
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
|
||||||
|
writeJson(conn, bodyJson)
|
||||||
|
val body = body(conn)
|
||||||
|
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
fun observations(credential: String, sessionId: String): String {
|
||||||
|
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
|
||||||
|
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
|
||||||
|
return body(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSession(credential: String, sessionId: String) {
|
||||||
|
open("/v1/sessions/$sessionId", "DELETE", credential).responseCode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The protocol crypto primitives, matching the server exactly (probe-protocol.md §2.4/§3.1):
|
||||||
|
* HMAC-SHA256 for the data-plane gate, and HKDF-SHA256 for the session key
|
||||||
|
* `HKDF(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id)`.
|
||||||
|
* JDK-only (javax.crypto) — no third-party crypto.
|
||||||
|
*/
|
||||||
|
object Crypto {
|
||||||
|
|
||||||
|
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
|
||||||
|
Mac.getInstance("HmacSHA256").run {
|
||||||
|
init(SecretKeySpec(key, "HmacSHA256"))
|
||||||
|
doFinal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First 4 bytes of HMAC-SHA256 — the wire anti-abuse gate (spec §3.1). */
|
||||||
|
fun hmac32(key: ByteArray, data: ByteArray): ByteArray = hmacSha256(key, data).copyOf(4)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HKDF-SHA256 (RFC 5869) extract-then-expand. The JDK exposes no HKDF, so it is built from
|
||||||
|
* HMAC — small and standard.
|
||||||
|
*/
|
||||||
|
fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
|
||||||
|
val prk = hmacSha256(if (salt.isEmpty()) ByteArray(32) else salt, ikm) // extract
|
||||||
|
val out = ByteArray(length)
|
||||||
|
var t = ByteArray(0)
|
||||||
|
var pos = 0
|
||||||
|
var counter = 1
|
||||||
|
while (pos < length) {
|
||||||
|
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(prk, "HmacSHA256")) }
|
||||||
|
mac.update(t)
|
||||||
|
mac.update(info)
|
||||||
|
mac.update(counter.toByte())
|
||||||
|
t = mac.doFinal()
|
||||||
|
val n = minOf(t.size, length - pos)
|
||||||
|
t.copyInto(out, pos, 0, n)
|
||||||
|
pos += n
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives the 32-byte session key for a session (spec §2.4). */
|
||||||
|
fun sessionKey(credential: String, keySalt: ByteArray, sessionId: String): ByteArray =
|
||||||
|
hkdfSha256(credential.toByteArray(), keySalt, "echolot-v1/$sessionId".toByteArray(), 32)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** Control-plane JSON shapes (probe-protocol.md §2). Only fields the client uses are modeled;
|
||||||
|
* unknown fields are ignored by the lenient Json in [ControlClient]. */
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class EnrollResponse(
|
||||||
|
@SerialName("device_id") val deviceId: String,
|
||||||
|
val credential: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Target(
|
||||||
|
val id: String,
|
||||||
|
val ip4: String? = null,
|
||||||
|
val ip6: String? = null,
|
||||||
|
@SerialName("udp_port") val udpPort: Int = 0,
|
||||||
|
@SerialName("tcp_port") val tcpPort: Int = 0,
|
||||||
|
@SerialName("stun_port") val stunPort: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SelfTest(
|
||||||
|
@SerialName("mtu_ok") val mtuOk: Boolean? = null,
|
||||||
|
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Profile(
|
||||||
|
@SerialName("profile_version") val profileVersion: Int = 0,
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("server_version") val serverVersion: String = "",
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
val targets: List<Target> = emptyList(),
|
||||||
|
@SerialName("canary_zone") val canaryZone: String = "",
|
||||||
|
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
|
||||||
|
val pins: List<String> = emptyList(),
|
||||||
|
) {
|
||||||
|
fun supports(capability: String) = capability in capabilities
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SessionResponse(
|
||||||
|
@SerialName("session_id") val sessionId: String,
|
||||||
|
@SerialName("key_salt") val keySalt: String, // base64
|
||||||
|
val epoch: String,
|
||||||
|
@SerialName("expires_s") val expiresS: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Observations bundle (§6). Kept as raw JSON where the shape is still evolving server-side. */
|
||||||
|
@Serializable
|
||||||
|
data class Observations(
|
||||||
|
val udp: JsonElement? = null,
|
||||||
|
val tcp: JsonElement? = null,
|
||||||
|
@SerialName("connect_back") val connectBack: JsonElement? = null,
|
||||||
|
@SerialName("dns_canary") val dnsCanary: JsonElement? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPKI-pinned trust (probe-protocol.md §1): the client trusts the server ONLY against the
|
||||||
|
* `pin-sha256` from enrollment — CA validation is not required and self-signed is first-class.
|
||||||
|
* The pin is base64(SHA-256(SubjectPublicKeyInfo)), RFC 7469.
|
||||||
|
*/
|
||||||
|
object Pinning {
|
||||||
|
|
||||||
|
fun spkiPin(cert: X509Certificate): String {
|
||||||
|
val spki = cert.publicKey.encoded // DER SubjectPublicKeyInfo
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(spki)
|
||||||
|
return java.util.Base64.getEncoder().encodeToString(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An SSLContext that accepts a chain iff its leaf SPKI matches one of the expected pins. */
|
||||||
|
fun sslContext(expectedPins: Set<String>): SSLContext {
|
||||||
|
val tm = object : X509TrustManager {
|
||||||
|
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw java.security.cert.CertificateException("empty chain")
|
||||||
|
val pin = spkiPin(leaf)
|
||||||
|
if (pin !in expectedPins) {
|
||||||
|
throw java.security.cert.CertificateException("SPKI pin mismatch: got $pin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) = Unit
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
return SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1
|
||||||
|
* packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
|
||||||
|
* server target. Blocking; the caller owns threading.
|
||||||
|
*/
|
||||||
|
class ProbeSession(
|
||||||
|
private val credential: String,
|
||||||
|
private val session: SessionResponse,
|
||||||
|
private val serverHost: String,
|
||||||
|
private val serverUdpPort: Int,
|
||||||
|
) : AutoCloseable {
|
||||||
|
|
||||||
|
private val key: ByteArray =
|
||||||
|
Crypto.sessionKey(credential, Base64.getDecoder().decode(session.keySalt), session.sessionId)
|
||||||
|
private val prefix: ByteArray = Wire.wirePrefix(session.sessionId)
|
||||||
|
private val epochNanos = System.nanoTime()
|
||||||
|
private val socket = DatagramSocket().apply { soTimeout = 3000 }
|
||||||
|
private val server = InetSocketAddress(serverHost, serverUdpPort)
|
||||||
|
private var seq = 0
|
||||||
|
|
||||||
|
private fun nowNs() = System.nanoTime() - epochNanos
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One ECHO round trip. Returns RTT in ms and the server's observation, or null on loss.
|
||||||
|
*
|
||||||
|
* The response is capped at the request size (§3.4 anti-amplification) and the observation
|
||||||
|
* block is 40 bytes, so the request must be at least header+40 = 72 bytes for the full
|
||||||
|
* observation to fit — hence the ≥40 default padding. Smaller requests still measure RTT.
|
||||||
|
*/
|
||||||
|
fun echo(paddingBytes: Int = 40): EchoResult? {
|
||||||
|
val t0 = System.nanoTime()
|
||||||
|
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, ++seq, nowNs(), key, ByteArray(paddingBytes))
|
||||||
|
socket.send(DatagramPacket(pkt, pkt.size, server))
|
||||||
|
val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null
|
||||||
|
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
|
||||||
|
return EchoResult(rttMs, Observation.parse(resp.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported).
|
||||||
|
* Returns the size the server acknowledged receiving, or null if the probe was lost. */
|
||||||
|
fun mtuProbe(totalSize: Int): Int? {
|
||||||
|
val payloadLen = (totalSize - Wire.HEADER_SIZE).coerceAtLeast(0)
|
||||||
|
val pkt = Wire.build(Wire.TYPE_MTU_PROBE, prefix, ++seq, nowNs(), key, ByteArray(payloadLen))
|
||||||
|
socket.send(DatagramPacket(pkt, pkt.size, server))
|
||||||
|
val resp = receive(Wire.TYPE_MTU_ACK) ?: return null
|
||||||
|
if (resp.payload.size < 4) return null
|
||||||
|
return ((resp.payload[0].toInt() and 0xFF) shl 24) or
|
||||||
|
((resp.payload[1].toInt() and 0xFF) shl 16) or
|
||||||
|
((resp.payload[2].toInt() and 0xFF) shl 8) or
|
||||||
|
(resp.payload[3].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collects packets the SERVER sends under a grant (downtrain / big_send) for [windowMs].
|
||||||
|
* These arrive unsolicited after a control-plane action, so this just drains the socket and
|
||||||
|
* keeps every HMAC-verified packet — anything that fails verification is not ours and is
|
||||||
|
* silently ignored (an injected packet must not be able to fake a measurement).
|
||||||
|
*/
|
||||||
|
fun collectGranted(windowMs: Long): List<Received> {
|
||||||
|
val out = ArrayList<Received>()
|
||||||
|
val deadline = System.nanoTime() + windowMs * 1_000_000
|
||||||
|
val buf = ByteArray(9200)
|
||||||
|
val prevTimeout = socket.soTimeout
|
||||||
|
try {
|
||||||
|
while (System.nanoTime() < deadline) {
|
||||||
|
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
|
||||||
|
if (remainMs <= 0) break
|
||||||
|
socket.soTimeout = remainMs.coerceAtMost(2000)
|
||||||
|
val dp = DatagramPacket(buf, buf.size)
|
||||||
|
try {
|
||||||
|
socket.receive(dp)
|
||||||
|
} catch (e: java.net.SocketTimeoutException) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val pkt = Wire.parseVerified(buf, dp.length, key) ?: continue
|
||||||
|
out.add(Received(pkt.type, pkt.seq, dp.length, (System.nanoTime() - epochNanos)))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
socket.soTimeout = prevTimeout
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One packet received from the server, with the wire size actually delivered. */
|
||||||
|
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
|
||||||
|
|
||||||
|
private fun receive(wantType: Int): Wire.Packet? {
|
||||||
|
val buf = ByteArray(2048)
|
||||||
|
return try {
|
||||||
|
val dp = DatagramPacket(buf, buf.size)
|
||||||
|
socket.receive(dp)
|
||||||
|
Wire.parseVerified(buf, dp.length, key)?.takeIf { it.type == wantType }
|
||||||
|
} catch (e: java.net.SocketTimeoutException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() = socket.close()
|
||||||
|
|
||||||
|
data class EchoResult(val rttMs: Double, val observation: Observation?)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The binary UDP probe protocol wire format (probe-protocol.md §3.1): a fixed 32-byte header
|
||||||
|
* plus payload, HMAC-gated. Mirrors the Go server's dataplane package byte-for-byte.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* 0 4 magic "ELT1" 8 8 session_prefix (first 8 bytes of session id)
|
||||||
|
* 4 1 type 16 4 seq
|
||||||
|
* 5 1 flags 20 8 t_ns (sender clock, ns since session epoch)
|
||||||
|
* 6 2 payload_len 28 4 hmac32(session_key, header[0..28] || payload)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
object Wire {
|
||||||
|
const val HEADER_SIZE = 32
|
||||||
|
val MAGIC = byteArrayOf('E'.code.toByte(), 'L'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte())
|
||||||
|
|
||||||
|
const val TYPE_ECHO_REQ: Int = 0x01
|
||||||
|
const val TYPE_ECHO_RESP: Int = 0x02
|
||||||
|
const val TYPE_TIMESYNC_REQ: Int = 0x07
|
||||||
|
const val TYPE_TIMESYNC_RSP: Int = 0x08
|
||||||
|
const val TYPE_MTU_PROBE: Int = 0x09
|
||||||
|
const val TYPE_MTU_ACK: Int = 0x0A
|
||||||
|
const val TYPE_DELAYED_ECHO: Int = 0x0B
|
||||||
|
/** Server->client under an asymmetric grant (spec §3.4/§5). */
|
||||||
|
const val TYPE_DOWNTRAIN_DATA: Int = 0x06
|
||||||
|
const val TYPE_BIG_SEND: Int = 0x0C
|
||||||
|
|
||||||
|
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
|
||||||
|
fun wirePrefix(sessionId: String): ByteArray {
|
||||||
|
require(sessionId.length >= 16) { "session id too short" }
|
||||||
|
val p = ByteArray(8)
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
p[i] = ((hex(sessionId[i * 2]) shl 4) or hex(sessionId[i * 2 + 1])).toByte()
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hex(c: Char): Int = when (c) {
|
||||||
|
in '0'..'9' -> c - '0'
|
||||||
|
in 'a'..'f' -> c - 'a' + 10
|
||||||
|
in 'A'..'F' -> c - 'A' + 10
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a signed packet ready to send. */
|
||||||
|
fun build(
|
||||||
|
type: Int, sessionPrefix: ByteArray, seq: Int, tNs: Long, key: ByteArray,
|
||||||
|
payload: ByteArray = ByteArray(0),
|
||||||
|
): ByteArray {
|
||||||
|
val buf = ByteBuffer.allocate(HEADER_SIZE + payload.size).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
buf.put(MAGIC)
|
||||||
|
buf.put(type.toByte())
|
||||||
|
buf.put(0) // flags
|
||||||
|
buf.putShort(payload.size.toShort())
|
||||||
|
buf.put(sessionPrefix, 0, 8)
|
||||||
|
buf.putInt(seq)
|
||||||
|
buf.putLong(tNs)
|
||||||
|
buf.position(28) // leave hmac slot; fill after
|
||||||
|
buf.putInt(0)
|
||||||
|
buf.put(payload)
|
||||||
|
val bytes = buf.array()
|
||||||
|
// HMAC over header[0..28] || payload (the hmac slot itself excluded).
|
||||||
|
val mac = Crypto.hmacSha256(key, concat(bytes, 0, 28, bytes, HEADER_SIZE, payload.size))
|
||||||
|
mac.copyInto(bytes, 28, 0, 4)
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parsed, HMAC-verified inbound packet. */
|
||||||
|
data class Packet(val type: Int, val seq: Int, val tNs: Long, val payload: ByteArray)
|
||||||
|
|
||||||
|
/** Parses and verifies an inbound datagram; null if malformed or the HMAC fails. */
|
||||||
|
fun parseVerified(data: ByteArray, len: Int, key: ByteArray): Packet? {
|
||||||
|
if (len < HEADER_SIZE) return null
|
||||||
|
for (i in MAGIC.indices) if (data[i] != MAGIC[i]) return null
|
||||||
|
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
val type = bb.get(4).toInt() and 0xFF
|
||||||
|
val payloadLen = bb.getShort(6).toInt() and 0xFFFF
|
||||||
|
if (HEADER_SIZE + payloadLen > len) return null
|
||||||
|
val expect = Crypto.hmacSha256(key, concat(data, 0, 28, data, HEADER_SIZE, payloadLen))
|
||||||
|
for (i in 0 until 4) if (expect[i] != data[28 + i]) return null
|
||||||
|
val seq = bb.getInt(16)
|
||||||
|
val tNs = bb.getLong(20)
|
||||||
|
val payload = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadLen)
|
||||||
|
return Packet(type, seq, tNs, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun concat(a: ByteArray, aOff: Int, aLen: Int, b: ByteArray, bOff: Int, bLen: Int): ByteArray {
|
||||||
|
val out = ByteArray(aLen + bLen)
|
||||||
|
a.copyInto(out, 0, aOff, aOff + aLen)
|
||||||
|
b.copyInto(out, aLen, bOff, bOff + bLen)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server observation block appended to ECHO_RESP (spec §3.3), fixed 40 bytes. */
|
||||||
|
data class Observation(
|
||||||
|
val tRxNs: Long, val tTxNs: Long, val observedPort: Int, val receivedSize: Int,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun parse(payload: ByteArray): Observation? {
|
||||||
|
if (payload.size < 40) return null
|
||||||
|
val bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN)
|
||||||
|
return Observation(
|
||||||
|
tRxNs = bb.getLong(0),
|
||||||
|
tTxNs = bb.getLong(8),
|
||||||
|
observedPort = bb.getShort(32).toInt() and 0xFFFF,
|
||||||
|
receivedSize = bb.getInt(36),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class CryptoWireTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hkdfMatchesRfc5869Vector() {
|
||||||
|
// RFC 5869 Appendix A.1 (SHA-256).
|
||||||
|
val ikm = ByteArray(22) { 0x0b }
|
||||||
|
val salt = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
|
||||||
|
val info = byteArrayOf(
|
||||||
|
0xf0.toByte(), 0xf1.toByte(), 0xf2.toByte(), 0xf3.toByte(), 0xf4.toByte(),
|
||||||
|
0xf5.toByte(), 0xf6.toByte(), 0xf7.toByte(), 0xf8.toByte(), 0xf9.toByte(),
|
||||||
|
)
|
||||||
|
val okm = Crypto.hkdfSha256(ikm, salt, info, 42)
|
||||||
|
val expect = "3cb25f25faacd57a90434f64d0362f2a" +
|
||||||
|
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
|
||||||
|
"34007208d5b887185865"
|
||||||
|
assertEquals(expect, okm.joinToString("") { "%02x".format(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wirePrefixDecodesHex() {
|
||||||
|
val prefix = Wire.wirePrefix("805a43f8395ae08ace7a14803766cb11")
|
||||||
|
assertEquals("805a43f8395ae08a", prefix.joinToString("") { "%02x".format(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildThenParseRoundTripsAndVerifies() {
|
||||||
|
val key = ByteArray(32) { it.toByte() }
|
||||||
|
val prefix = ByteArray(8) { (it + 1).toByte() }
|
||||||
|
val payload = "hello-echolot".toByteArray()
|
||||||
|
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, 7, 123_456L, key, payload)
|
||||||
|
assertEquals(Wire.HEADER_SIZE + payload.size, pkt.size)
|
||||||
|
|
||||||
|
val parsed = Wire.parseVerified(pkt, pkt.size, key)
|
||||||
|
assertNotNull(parsed)
|
||||||
|
assertEquals(Wire.TYPE_ECHO_REQ, parsed.type)
|
||||||
|
assertEquals(7, parsed.seq)
|
||||||
|
assertEquals(123_456L, parsed.tNs)
|
||||||
|
assertEquals("hello-echolot", String(parsed.payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tamperedHmacIsRejected() {
|
||||||
|
val key = ByteArray(32) { it.toByte() }
|
||||||
|
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, key, ByteArray(4))
|
||||||
|
pkt[pkt.size - 1] = (pkt[pkt.size - 1].toInt() xor 0xFF).toByte() // flip a payload byte
|
||||||
|
assertNull(Wire.parseVerified(pkt, pkt.size, key))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongKeyIsRejected() {
|
||||||
|
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, ByteArray(32) { 1 }, ByteArray(0))
|
||||||
|
assertNull(Wire.parseVerified(pkt, pkt.size, ByteArray(32) { 2 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun observationParses() {
|
||||||
|
// 40-byte block: t_rx, t_tx, 16-byte addr, port, ttl/dscp, size.
|
||||||
|
val b = ByteArray(40)
|
||||||
|
b[33] = 0x1F // port low byte = 8191... set port bytes 32..33
|
||||||
|
b[32] = 0x00
|
||||||
|
val obs = Observation.parse(b)
|
||||||
|
assertNotNull(obs)
|
||||||
|
assertTrue(obs.observedPort in 0..65535)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.protocol
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End-to-end test of the Kotlin client against a REAL running server. It self-skips unless the
|
||||||
|
* environment provides a live target, so it never breaks CI (no network / no server):
|
||||||
|
*
|
||||||
|
* ECHOLOT_LIVE_URL = https://fmr-1.echo-lot.app:8443
|
||||||
|
* ECHOLOT_LIVE_PIN = <base64 pin-sha256>
|
||||||
|
* ECHOLOT_LIVE_CRED = <device credential from an enrollment>
|
||||||
|
* ECHOLOT_LIVE_UDP = fmr-1.echo-lot.app:8442
|
||||||
|
* ECHOLOT_LIVE_TARGET = fmr (profile target id)
|
||||||
|
*
|
||||||
|
* The harness (test-fmr.sh) mints a token over SSH, enrolls via the public control plane, and
|
||||||
|
* exports these — proving the client talks to the deployed server over the wire.
|
||||||
|
*/
|
||||||
|
class LiveServerTest {
|
||||||
|
|
||||||
|
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 fullFlowAgainstLiveServer() {
|
||||||
|
if (url == null || pin == null || cred == null || udp == null) {
|
||||||
|
println("LiveServerTest skipped (no ECHOLOT_LIVE_* env)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val control = ControlClient(url, setOf(pin))
|
||||||
|
|
||||||
|
val profile = control.profile(cred)
|
||||||
|
println("profile: name=${profile.name} v=${profile.serverVersion} caps=${profile.capabilities}")
|
||||||
|
assertTrue(profile.supports("udp-probe"), "server must offer udp-probe")
|
||||||
|
|
||||||
|
val session = control.createSession(cred, target)
|
||||||
|
println("session: ${session.sessionId} expires=${session.expiresS}s")
|
||||||
|
|
||||||
|
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
|
||||||
|
ProbeSession(cred, session, host, port).use { ps ->
|
||||||
|
// ECHO: verified response + observation with our observed port.
|
||||||
|
val echo = ps.echo(paddingBytes = 64) // ≥40 so the observation block fits (§3.4)
|
||||||
|
assertNotNull(echo, "no verified ECHO_RESP from live server")
|
||||||
|
println("echo rtt=${"%.1f".format(echo.rttMs)}ms observedPort=${echo.observation?.observedPort} size=${echo.observation?.receivedSize}")
|
||||||
|
assertNotNull(echo.observation, "ECHO_RESP missing observation block")
|
||||||
|
|
||||||
|
// MTU probe: server acks the size it received.
|
||||||
|
val acked = ps.mtuProbe(1400)
|
||||||
|
assertNotNull(acked, "no MTU_ACK from live server")
|
||||||
|
println("mtu probe 1400 -> server received $acked bytes")
|
||||||
|
assertTrue(acked!! in 1300..1500, "acked size implausible: $acked")
|
||||||
|
}
|
||||||
|
|
||||||
|
val obs = control.observations(cred, session.sessionId)
|
||||||
|
println("observations bytes: ${obs.length}")
|
||||||
|
assertTrue(obs.contains("packets_seen"), "observations should report packets_seen")
|
||||||
|
control.deleteSession(cred, session.sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
// AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Shizuku (shell-tier) executor + probe. Ported from the validated
|
||||||
|
// echolot-prober with the build-4 dual-path fix: bind the UserService when it
|
||||||
|
// works (OnePlus), fall back to the legacy newProcess API when it doesn't
|
||||||
|
// (Lenovo). Emits a core-measurement Test.
|
||||||
|
android {
|
||||||
|
namespace = "app.echo_lot.shizuku"
|
||||||
|
compileSdk = 36
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 26
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
aidl = true // IUserService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core-measurement"))
|
||||||
|
implementation(libs.shizuku.api)
|
||||||
|
implementation(libs.shizuku.provider)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
|
||||||
|
|
||||||
|
<!-- Android 11+ package visibility: needed to tell "Shizuku installed but stopped"
|
||||||
|
(worth a reminder) apart from "not installed" (say nothing). -->
|
||||||
|
<queries>
|
||||||
|
<package android:name="moe.shizuku.privileged.api" />
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<!-- Shizuku binder provider (merged into the host app). -->
|
||||||
|
<provider
|
||||||
|
android:name="rikka.shizuku.ShizukuProvider"
|
||||||
|
android:authorities="${applicationId}.shizuku"
|
||||||
|
android:multiprocess="false"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package app.echo_lot.shizuku;
|
||||||
|
|
||||||
|
interface IUserService {
|
||||||
|
void destroy();
|
||||||
|
String exec(String command, int timeoutMs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package app.echo_lot.shizuku
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import rikka.shizuku.Shizuku
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the UI whether the shell tier is usable **before** a run starts, so the app can say
|
||||||
|
* "Shizuku isn't running, shell-tier tests will be skipped" instead of silently producing an
|
||||||
|
* UNSUPPORTED result minutes later.
|
||||||
|
*
|
||||||
|
* Detection is reliable but *asynchronous*: `Shizuku.pingBinder()` answers truthfully only once
|
||||||
|
* ShizukuProvider has delivered the binder to this process, which happens shortly after start.
|
||||||
|
* [observe] therefore reports the current state immediately and again whenever the binder
|
||||||
|
* arrives or dies — a poll at t=0 alone would show a false "not running".
|
||||||
|
*/
|
||||||
|
object ShizukuAvailability {
|
||||||
|
|
||||||
|
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
|
||||||
|
|
||||||
|
enum class State {
|
||||||
|
/** Running and this app may use it — shell tier will execute. */
|
||||||
|
READY,
|
||||||
|
/** Running, but the user hasn't granted this app permission yet (we can ask). */
|
||||||
|
NEEDS_PERMISSION,
|
||||||
|
/** Shizuku IS installed but its service isn't started — worth reminding this user. */
|
||||||
|
INSTALLED_NOT_RUNNING,
|
||||||
|
/** Shizuku isn't installed at all — say nothing; this user doesn't use the shell tier. */
|
||||||
|
NOT_INSTALLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is the Shizuku manager installed? Needs the <queries> entry on Android 11+. */
|
||||||
|
fun isInstalled(context: Context): Boolean = runCatching {
|
||||||
|
context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
fun current(context: Context): State = when {
|
||||||
|
runCatching { Shizuku.pingBinder() }.getOrDefault(false) ->
|
||||||
|
if (runCatching { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED }
|
||||||
|
.getOrDefault(false)
|
||||||
|
) State.READY else State.NEEDS_PERMISSION
|
||||||
|
isInstalled(context) -> State.INSTALLED_NOT_RUNNING
|
||||||
|
else -> State.NOT_INSTALLED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI one-liner, or null when nothing should be said. Users without Shizuku get no nag; users
|
||||||
|
* who have it installed but stopped get the reminder that makes the difference between a
|
||||||
|
* full run and a silently skipped shell tier.
|
||||||
|
*/
|
||||||
|
fun describe(s: State): String? = when (s) {
|
||||||
|
State.READY -> "Shizuku ready — shell-tier tests will run"
|
||||||
|
State.NEEDS_PERMISSION -> "Shizuku is running but not authorised — it will ask on first use"
|
||||||
|
State.INSTALLED_NOT_RUNNING -> "Shizuku is installed but not running — start it to include shell-tier tests"
|
||||||
|
State.NOT_INSTALLED -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent that opens the Shizuku manager.
|
||||||
|
*
|
||||||
|
* Only `MainActivity` is reachable: verified against Shizuku 13.6's own manifest, the
|
||||||
|
* wireless-debugging entry points — `moe.shizuku.manager.adb.AdbPairingTutorialActivity`,
|
||||||
|
* `moe.shizuku.manager.adb.AdbPairingService` and `moe.shizuku.manager.starter.StarterActivity`
|
||||||
|
* — declare no intent filters, so they are NOT exported and a third-party app cannot launch
|
||||||
|
* them (MainActivity itself only answers MAIN/LAUNCHER, no deep link). Landing on the main
|
||||||
|
* screen and telling the user which button to press is therefore the best available handoff.
|
||||||
|
*/
|
||||||
|
fun launchIntent(context: Context): android.content.Intent? =
|
||||||
|
context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)
|
||||||
|
?.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
/** Asks Shizuku for permission (only meaningful while it is running). */
|
||||||
|
fun requestPermission(requestCode: Int = 0xE1) {
|
||||||
|
runCatching { Shizuku.requestPermission(requestCode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What tapping the notice does. For the stopped case the label names the exact steps inside
|
||||||
|
* Shizuku, because we can only hand the user to its main screen (see [launchIntent]).
|
||||||
|
*/
|
||||||
|
fun actionHint(s: State): String? = when (s) {
|
||||||
|
State.INSTALLED_NOT_RUNNING ->
|
||||||
|
"Tap to open Shizuku → \"Pairing\" to pair, then \"Start\" (wireless debugging)"
|
||||||
|
State.NEEDS_PERMISSION -> "Tap to grant permission"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Developer-options screen, where Wireless debugging is enabled — the prerequisite Shizuku's
|
||||||
|
* wireless start depends on. This action IS public and exported, unlike Shizuku's own
|
||||||
|
* pairing screen.
|
||||||
|
*/
|
||||||
|
fun developerOptionsIntent(): android.content.Intent =
|
||||||
|
android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
|
||||||
|
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports the state now and on every binder transition. Returns a function that removes the
|
||||||
|
* listeners again (call it from onCleared).
|
||||||
|
*/
|
||||||
|
fun observe(context: Context, onChange: (State) -> Unit): () -> Unit {
|
||||||
|
val app = context.applicationContext
|
||||||
|
val received = Shizuku.OnBinderReceivedListener { onChange(current(app)) }
|
||||||
|
val dead = Shizuku.OnBinderDeadListener { onChange(current(app)) }
|
||||||
|
// "Sticky" fires immediately if the binder already arrived before we registered.
|
||||||
|
runCatching { Shizuku.addBinderReceivedListenerSticky(received) }
|
||||||
|
runCatching { Shizuku.addBinderDeadListener(dead) }
|
||||||
|
onChange(current(app))
|
||||||
|
return {
|
||||||
|
runCatching { Shizuku.removeBinderReceivedListener(received) }
|
||||||
|
runCatching { Shizuku.removeBinderDeadListener(dead) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||