Compare commits
@@ -40,3 +40,6 @@ keystore.properties
|
||||
|
||||
# wrangler build/dev artifacts
|
||||
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;
|
||||
Lenovo TB330FU (A15, multi-user) — UserService never binds, the `newProcess` fallback carries
|
||||
it. Full history in build-status.md.
|
||||
- **Wireless-adb beacon** (`echolot-app/adb-beacon` + `tools/adb-beacon/receiver.py`): the
|
||||
wireless-debug port rotates every couple of minutes on these devices; the beacon reads the live
|
||||
port from adbd's own mDNS (`_adb-tls-connect._tcp`, filtered to the device's own IP) and POSTs
|
||||
it to fmr:443 (cleartext allowed; over a validated net); a PC connector polls and reconnects.
|
||||
Bootstrap needs one manual adb connection to install it, then it self-heals. Existing adb
|
||||
connections survive port rotation — only fresh connects need the new port.
|
||||
**Resolve adbd's mDNS advertisement exactly ONCE per service instance** (re-resolving makes
|
||||
adbd re-arm the connection, which spams "wireless debugging connected" notifications); the
|
||||
periodic heartbeat only re-POSTs the cached port. Rotation is still caught: onServiceLost
|
||||
clears the guard, so the new advertisement is resolved once and reported within seconds
|
||||
(verified: 37089 -> 33667 reported 6 s after rotation).
|
||||
**Known limitation — do not run the beacon on the OnePlus.** On network churn (SSID jump, roam)
|
||||
adbd repeatedly drops and re-publishes its advertisement, so lost/found cycles keep clearing the
|
||||
resolve-guard; each resolve makes adbd re-arm and post a "wireless debugging connected"
|
||||
notification. Guarding reduces but cannot eliminate this — resolving adbd's own mDNS record is
|
||||
inherently noisy on that device. Use manual `ip:port` there (ask the user), or read the port via
|
||||
Shizuku (`ss -tlnp | grep adbd`, no mDNS) if the shell tier is available.
|
||||
- **Shizuku start kills the adb bridge and the beacon can't auto-recover it.** Shizuku's non-root
|
||||
start pairs over wireless debugging and runs its starter through adb, hijacking the channel:
|
||||
adb goes device→offline→refused, and adbd keeps advertising the now-dead port over mDNS (stale),
|
||||
so the beacon reports a port that no longer accepts connections. Workaround: after starting
|
||||
Shizuku, **toggle Wireless debugging off/on** — Shizuku keeps running (separate process), a fresh
|
||||
port + mDNS record appear, and the beacon/connector recover. Plan the Shizuku-tier dev loop
|
||||
around this (or USB, if ever available).
|
||||
- As of the AGP 9.2.0 / Gradle 9.6.0 / Kotlin 2.2.10 bump, JDK 17+ (including 25) works —
|
||||
AGP 9 requires Gradle 9.1.0+ and Kotlin 2.2.10+ as its minimum KGP version. On machines with
|
||||
Android Studio, its `jbr` directory works as `JAVA_HOME`.
|
||||
|
||||
@@ -271,3 +271,275 @@ against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, chec
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||