Compare commits

..
Author SHA1 Message Date
mrambossekandClaude Fable 5 ce1aaa332a server: send granted traffic from the address the session actually used
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
fmr binds two IPv4 addresses. connFor picked whichever socket of the right
family came first in the bind list, so a downtrain for a session established on
.150 went out from .151 — and every packet was dropped by the client's NAT,
which has no mapping for that pair. tcpdump on the server showed all 50 leaving;
the client saw none. Read as "100% downstream loss", which is the worst kind of
wrong: a confident measurement of something that never happened.

Sessions now record which of our own bound addresses received their traffic, and
granted sends (and delayed echo) go back out through that socket. The fallback
to a family match is kept for the case where nothing has been received yet, and
the test pins both paths — a single-homed lab can never reproduce this.

Also: the client-side halves of the same work — anonymizer (core-privacy), local
run archive with retention (core-archive), upload client, and the app's settings
and history screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:45:43 +02:00
mrambossekandClaude Fable 5 7a94c9a3d7 chore: ignore the VSCodium Java extension's bin/ output
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 29s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:30 +02:00
mrambossekandClaude Fable 5 2521d39989 server: DF-mode big_send + uploaded-run storage with an operator policy
big_send now forces the Don't-Fragment bit for the whole burst by default, so
the largest size that arrives IS the downstream path MTU rather than "fragments
got through" — two different measurements the schema already separates. Sizes
above our own egress MTU (from the startup self-test) are refused up front and
reported as max_df_bytes, because absence caused by our kernel must not be read
as a limit of the client's path.

Uploads: one JSON file per run under the state dir, with the policy the operator
actually cares about — who may upload (off / anonymous / account), how large,
how long to keep, and the least anonymization accepted. The profile advertises
all of it so the app can present the switch honestly instead of discovering the
rules by failing. `account` refuses today rather than falling back to anonymous:
picking the strict setting before OIDC lands must not silently mean the loose one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:26:19 +02:00
mrambossekandClaude Opus 5 7e1015c211 server: §3.4 asymmetric grants + downtrain and big_send actions
server-test / test (push) Successful in 29s
server-release / image (push) Successful in 34s
server-release / release (push) Successful in 29s
The grant is the keystone that makes server->client sends safe: created
only by an authenticated control-plane action, bound at creation to the
session's OBSERVED data-plane source (so it can never be aimed at a third
party), and bounded by bytes, average rate and expiry. Sends stop the
moment the budget runs out, so a buggy action cannot become a flood.

Two granted actions on top of it:
- downtrain: N packets at a given size/interval toward the client, with
  seq + send-timestamp in the payload — downstream loss/reorder/jitter,
  which an upstream-only train cannot measure.
- big_send: one datagram per requested size, echoing the intended size in
  the payload — downstream MTU / black-hole evidence the client cannot
  produce for itself (only the far end can emit a large packet toward it).

Tests cover the security properties: no grant without a verified
destination, client requests clamped to server limits, byte budget stops
sending exactly, expiry refuses, and the rate ceiling throttles a burst.
Capabilities gain downtrain + big-send.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:09:13 +02:00
mrambossekandClaude Opus 5 c75a9f5eb7 app: accurate Shizuku handoff — name the steps, add developer-options shortcut
Verified against Shizuku 13.6's manifest (pulled APK, aapt2 xmltree): its
wireless-debugging entry points (AdbPairingTutorialActivity,
AdbPairingService, StarterActivity) have no intent filters, so they are
not exported and cannot be launched externally; MainActivity answers only
MAIN/LAUNCHER with no deep link. Starting wireless debugging from another
app is therefore not possible, which is why the handoff lands on the
root-start screen.

Instead the hint now names the exact steps inside Shizuku ("Pairing", then
"Start"), and a second tap opens Developer options — that action IS public
and exported, and Wireless debugging has to be on before Shizuku's
wireless start works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 10:03:03 +02:00
mrambossekandClaude Opus 5 1d9e2063bf app: make the Shizuku banner actionable (open Shizuku / request permission)
A third-party app cannot start Shizuku — the wireless-debugging pairing
flow is privileged and lives in Shizuku's own app — so the banner
deep-links there when it is installed but stopped, and fires the
permission request directly when it is running but unauthorised. The hint
line says which.

Verified on-device together with the earlier UX work: progress bar showing
"test 4 of 8 · icmp.ping6 · ~33s left", Cancel beside the disabled Run
button, cutout-safe title, and the banner live-updating from
not-running to needs-permission via the binder listener.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:58:39 +02:00
mrambossekandClaude Opus 5 d7dda40e4e app: tell the user before the run when Shizuku is installed but not started
Distinguishes not-installed (say nothing — don't nag users who don't use
Shizuku) from installed-but-stopped (amber banner: start it to include
shell-tier tests), plus running-unauthorised and ready. Detection is
listener-based since pingBinder() only becomes truthful once
ShizukuProvider delivers the binder; a launch-time poll would show a false
"not running". Installed-vs-not needs the <queries> entry on Android 11+.

Verified on-device: with shizuku_server stopped, the banner shows before
pressing Run; the title also now clears the status bar/cutout after the
safeDrawingPadding fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:51:01 +02:00
mrambossekandClaude Opus 5 217818f7b3 app: progress bar with ETA, cancel button, and cutout-safe layout
- Probe.estimatedMs (measured per probe; timeout-bound ones dominate)
  drives a determinate progress bar and "test N of M · ~Xs left",
  including the Shizuku battery in the total.
- Cancel stops the run and shows the partial results as a normal document
  (findings + verdict over what was collected) but never uploads them.
- safeDrawingPadding() on the root column: Android 15 is edge-to-edge by
  default and the title was colliding with the status-bar clock and the
  camera cutout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:48:18 +02:00
mrambossekandClaude Opus 5 2e34463c0a app: router identification verified on-device — named a MikroTik RouterOS 7.23.2
The probe identified the LAN's IPv6 RA sender end to end from an
unprivileged app: EUI-64 MAC recovery (78:9A:18:54:B8:F9, matching the
Shizuku neighbor table) -> MikroTik by OUI, corroborated by the UPnP
device description (RouterOS/7.23.2, MikroTik Router) and reverse DNS
(router.hudelist.local). Cellular's RFC 7217 privacy RA source is
correctly reported as not-EUI-64 instead of guessed. The SSDP sweep also
inventoried a Synology DS1522+ and a Sky gateway — the raw material for
future LLDP/mDNS cross-matching.

Fixes found by running it: added the confirmed MikroTik OUI 78:9A:18 (+
other RouterBOARD ranges) and an elvis-operator bug that printed "no UPnP
response" alongside valid UPnP data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:42:06 +02:00
mrambossekandClaude Opus 5 38f8036252 app: link.ra_source router identification + brand icons + DEV build variant
link.ra_source answers "who advertises IPv6 here, and which box is it":
RA source per network, MAC recovered from the modified-EUI-64 link-local
(privacy addresses reported as such, not guessed), vendor via a curated
OUI table, UPnP/SSDP M-SEARCH for the gateway's server banner + device
description (manufacturer/model/friendly name), and reverse DNS. All SSDP
responders are recorded so a rogue RA sender that isn't the gateway can
still be matched; the MAC accompanies every identity source as the hook
for future LLDP/mDNS cross-matching. UI gains a "Router / IPv6 advertiser"
panel.

Icons: branding adaptive icon converted to vector drawables (+ PNG
mipmaps, monochrome layer). The debug build is now a separate app —
applicationIdSuffix .dev, label "Echolot DEV", DEV-badged icon — so it
installs alongside a production build and can't be confused with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:35:54 +02:00
mrambossekandClaude Opus 5 dc094d1631 app: autorun mode (unattended run + upload + auto-exit); IPv6 severity rework
IPv6: absence is no longer a defect. If the network never provisioned v6
(no global address, no ::/0 route) the finding is ipv6.not_offered at INFO
(green) — most networks are still IPv4-only. If v6 IS advertised but
doesn't work, it's ipv6.broken at MEDIUM (yellow), because half-working v6
stalls connections. Verified on-device: our LAN advertises a v6 default
route with no path, and now reports ipv6.broken.

Autorun: `am start ... --ez autorun true` runs the suite immediately,
POSTs the report to the collection endpoint, shows the result for 3s and
finishes the activity (stays open if the upload failed). receiver.py gains
POST /report + GET /reports + GET /report/<name>. Verified end to end: one
adb command, report retrieved over HTTP, app closed itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:27:04 +02:00
mrambossekandClaude Opus 5 483de5ca54 app: nat.stun_5780 — NAT mapping/filtering discovery, verified vs live server
Hand-rolled RFC 5389/5780 STUN client (stdlib only) that exercises the
server's stun-5780 capability: one socket, three binding requests
(primary, OTHER-ADDRESS alternate IP, CHANGE-REQUEST port) — the
comparison classifies NAT mapping and filtering behavior.

Verified on the OnePlus: local 10.13.102.124 -> mapped
178.191.120.247:53259 (behind_nat true), alternate address answered from
the server's second IP, mapping endpoint-independent, filtering
address/port-dependent. Finding nat.symmetric (medium) for the
P2P-hostile case.

Two real bugs found by running it: port preservation was misread as "no
NAT" (compare addresses, not ports), and an unbound socket reports the
wildcard local address (resolve via a throwaway connected socket).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:15:29 +02:00
mrambossekandClaude Opus 5 dd9ecf5032 app: Shizuku shell tier verified on-device — 7/7 via UserService
Ran on the OnePlus with Shizuku started: tiers.shizuku=true, test ok,
commands_ok 7/7, exec_path=UserService (dual-path executor picked the
right path for this device). Evidence includes the live neighbor table,
per-table v6 routes, a real [NEIGH] netlink event, IpClient DHCP logs with
APF caps, and the wifi dump — as shell(2000).

Both v1 privilege tiers (app + shizuku) now verified end to end in the
production app on hardware. Report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:06:24 +02:00
mrambossekandClaude Opus 5 59ba1c16bc app: dns.canary probe — client half of the canary measurement, verified live
Resolves the server's canary zone through the platform resolver and
compares against the spec-frozen ground truth (probe-protocol §6.1):
reference records detect answers rewritten in flight, and a per-run nonce
name (uncacheable) proves the query reached the authoritative server.
Findings: dns.answer_rewritten (high), dns.authoritative_unreachable
(medium).

Verified on the OnePlus against the deployed fmr zone: 4/4 reference
records matched exactly, nonce name answered 192.0.2.21 with
reached_authoritative=true. First full client<->server measurement loop
on real hardware; report archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 09:02:43 +02:00
mrambossekandClaude Opus 5 cf5cd2dc68 CLAUDE.md: record the beacon's inherent mDNS noise limitation
Network churn (SSID jump/roam) makes adbd re-publish its advertisement
repeatedly; each resolve re-arms the connection and posts a notification,
so the resolve-once guard can't fully prevent spam on the OnePlus. Noted
the alternatives (manual port, or Shizuku `ss` with no mDNS involved).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 08:55:44 +02:00
mrambossekandClaude Opus 5 68a6bcb9e3 CLAUDE.md: record the beacon's resolve-once rule and verified rotation behavior
Re-resolving adbd's own mDNS advertisement is what caused the notification
spam; resolving once per service instance (guard cleared on loss) keeps
rotation tracking intact — verified live, a 37089->33667 rotation was
reported 6s later and reconnected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 08:50:48 +02:00
mrambossekandClaude Opus 5 b751ca771e app: net.captive_portal verified on-device; archive first app run report
OnePlus 15 run: Android's generate_204 logic reproduced correctly —
default+wifi 204/204 -> validated, cellular -1/-1 -> no_internet (a
per-network asymmetry the OS itself hides). Shizuku tier degraded
correctly to UNSUPPORTED with tiers.shizuku=false since Shizuku isn't
running there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:17:35 +02:00
mrambossekandClaude Opus 5 4c51bd2aad beacon: stop causing "wireless debugging connected" notification spam
Root cause of the spam the user kept seeing: the service resolved adbd's
own mDNS advertisement repeatedly (every discovery callback, plus a 20s
heartbeat). Resolving that service makes adbd re-arm the connection, and
Android posts a "wireless debugging connected" notification each time —
so the beacon itself was the noise source, independent of the PC-side
connector loops.

Now: resolve each discovered service instance exactly ONCE (guard set,
cleared on onServiceLost so a genuine rotation re-resolves once), and the
heartbeat only re-POSTs the cached port (60s, no mDNS traffic).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:12:14 +02:00
mrambossekandClaude Opus 5 ee4031b086 tools: beacon receiver can serve a staged APK on /apk (443)
A test device that can only reach fmr on 443 (LAN blocks other outbound
ports) can pull an APK via its own downloader — more resilient than adb's
sustained transport over flaky wifi. GET /apk serves APK_PATH. (Doesn't
help the Lenovo tablet, which ships no curl; kept for devices that do.)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:08:09 +02:00
mrambossekandClaude Opus 5 6e269d424b app: net.captive_portal probe — reproduce Android's internet/portal checks
Mirrors NetworkMonitor: per active network, fetch the AOSP default
generate_204 endpoints and check for HTTP 204 No Content.
- HTTPS https://www.google.com/generate_204 == 204 -> validated internet
- HTTP http://connectivitycheck.gstatic.com/generate_204: 204 -> clean;
  an unfollowed 3xx or a 200-with-body -> captive portal (Location captured)
- both fail -> no_internet
Per-network verdicts (bound via Network.openConnection), redirects not
followed (the 3xx IS the evidence). Findings: captive_portal (medium) and
no_internet (high). New test type net.captive_portal (net family ->
connectivity category). App gains usesCleartextTraffic (a network
diagnostic that intentionally probes plain HTTP).

Builds; measurement verdict tests still green. On-device verification
deferred with the rest (flaky test devices).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 23:01:22 +02:00
mrambossekandClaude Opus 5 aaed22dd3f app: core-shizuku — dual-path shell-tier executor + probe, wired into the app
Ports the prober's validated Shizuku tier: AIDL UserService, the build-4
dual-path ShizukuRunner (UserService bind where it works, legacy
newProcess reflection fallback where it doesn't — exec_path records
which), and ShizukuProbe running the shell command battery, emitting a
shizuku-tier link.ip_monitor Test with per-device dumps as evidence.
Self-degrades to UNSUPPORTED without Shizuku.

Wired into RunViewModel (sets tiers.shizuku); app APK assembles. On-device
verification deferred — no device reachable at build time (flaky LAN
dropped the tablet, phone debugging off). Expect UserService on OnePlus,
newProcess on Lenovo per the prober.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:37:48 +02:00
mrambossekandClaude Opus 5 9809ae57b4 tools: connector must not chase port rotation on a live connection
The connector tore down a working adb link whenever the beacon reported a
new port, reconnecting every loop and spamming the phone with "wireless
debugging connected" notifications (~every 8s). Existing connections
survive rotation, so now: if any device-state entry exists for the IP,
leave it; only (dis)connect when there's no working link at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:29:03 +02:00
mrambossekandClaude Opus 5 9bb3ac9df4 tools: version the beacon PC-connector; confirm Shizuku-toggle recovery
connect.sh polls the fmr beacon map and keeps `adb connect` current for
every device, now handling offline/stale entries and port changes
(disconnect+reconnect). Verified end to end: after Shizuku start + a
wireless-debugging toggle, the tablet's port rotated 38309->46667, the
beacon caught it, the connector reconnected, and Shizuku kept running as
an independent shell(2000) process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:26:09 +02:00
mrambossekandClaude Opus 5 96422dd58c CLAUDE.md: document the beacon + the Shizuku-kills-adb finding
Verified live: starting Shizuku (non-root, via wireless debugging) hijacks
the debug channel — adb drops and adbd advertises a stale mDNS port, so the
beacon can't auto-recover. Workaround: toggle wireless debugging off/on
after starting Shizuku (it keeps running). Baked into the dev notes so the
Shizuku-tier build loop plans around it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:23:58 +02:00
mrambossekandClaude Opus 5 16836ea7b1 build-status: production app verified on both devices via beacon-managed adb
Overall YELLOW on OnePlus 15 (A16) + Lenovo TB330FU (A15), driven by the
real broken-LAN IPv6 finding — full probe→schema→verdict→UI vertical on
hardware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:16:23 +02:00
mrambossekandClaude Opus 5 7b9b312dcd beacon: own-IP filter (shared-LAN fix) + wireless-debugging-off warning
Live multi-device test exposed two things:
- On a shared LAN, NsdManager discovers EVERY device's
  _adb-tls-connect._tcp advertisement, so a phone reported the tablet's
  port for its own IP (crossed). Now only accept the resolved service
  whose host matches this device's own wlan0 IP.
- When Wireless debugging is turned off, adbd drops its mDNS
  advertisement (onServiceLost) — the app now says so plainly in the
  status line and the ongoing notification ("Wireless debugging appears
  OFF — re-enable it"), instead of a vague "waiting".

Verified with phone + tablet on the same LAN: correct per-device ports,
both auto-connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:13:13 +02:00
mrambossekandClaude Opus 5 38d4440412 tools: beacon fixes — cleartext, multi-device, validated-net POST, status URL
Debugging against a real restricted LAN surfaced three fixes:
- Android blocks app cleartext HTTP by default (targetSdk 36) — the port
  discovery + reachability were fine (phone curl reached fmr), only the
  app POST was denied. Added usesCleartextTraffic for this dev tool.
- Multi-device: report + receiver are keyed by device (Build.MODEL) so a
  phone and tablet don't clobber each other; connector connects each.
- POST over a VALIDATED internet network (prefer cellular) since the
  wireless-debug wifi is often a restricted LAN.
- Status/notification now show the target beacon URL + which network, per
  the request to surface what it's connecting to.
Verified live: beacon tracks the (frequently rotating) port via mDNS and
self-reports the current endpoint within seconds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 22:07:35 +02:00
mrambossekandClaude Opus 5 52748ac853 tools: wireless-adb beacon — self-healing bridge across drops + port rotation
The phone's wireless-debug port rotates and the bridge drops; this makes
adb reconnect automatically. Three pieces:
- adb-beacon (Android dev app): reads adbd's own mDNS advertisement
  (_adb-tls-connect._tcp) via NsdManager for the live connect port — no
  root, no Shizuku — plus the wlan0 IPv4, and POSTs {ip,port} to fmr every
  time it changes (continuous NSD discovery catches rotation in seconds).
  Foreground service (specialUse) so it survives backgrounding.
- tools/adb-beacon/receiver.py: ~30-line rendezvous on fmr:9099 (secret-
  gated POST stores the latest endpoint; GET returns it). Deployed as
  echolot-adb-beacon.service.
- PC connector polls the endpoint and keeps `adb connect` current.

Dev tooling, separate from the product. Bootstrap: sideload the beacon
APK once (no adb needed); thereafter adb self-heals for everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:50:33 +02:00
mrambossekandClaude Opus 5 bc220f950e app: installable APK — core-probe (device-tier) + Compose UI
First assembling build of the production app. Android toolchain mirrors
the prober (AGP 9 built-in Kotlin; applying kotlin.android too
double-registers the kotlin extension — the one gotcha).

core-probe (Android lib): Probe→core-measurement Test abstraction;
NetworkInventory (LinkProperties→networks[]), LinkSnapshotProbe,
per-network IcmpProbe (ported from the prober's validated logic).

app (Compose): RunViewModel orchestrates probes into a MeasurementDocument
with a §7.3 summary + first-pass findings; UI renders traffic lights,
networks, tests, findings; JSON export. Rotation-safe (ViewModel).
App-tier only; server-facing (core-engine) + Shizuku are additive
follow-ups. Debug APK 9.5 MB, assembles clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:40:42 +02:00
mrambossekandClaude Opus 5 49c6197aff app: core-engine — run engine; full server-facing vertical proven vs fmr
Composes core-protocol probes into core-measurement documents. Injected
clock/UUID source keeps it pure and unit-testable. Runs a server ECHO
train and derives RTT distribution, loss, and NAT-rebinding detection
(from the server's observed source port) as train.udp_updown, then
findings + a §7.3 summary.

Verified end-to-end against fmr: 20-packet train, 0% loss, RTT
1.7/2.5/6.9ms, no rebinding → valid MeasurementDocument (2.3kB), overall
GREEN. The whole server-facing stack (protocol → engine → schema →
verdict) now produces the real product artifact against the live server,
no device required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:28:09 +02:00
mrambossekandClaude Opus 5 1f8860f7f8 app: core-measurement — the measurement-schema.md document model
Pure Kotlin/JVM, faithful to the schema contract: two-clock (wall RFC3339 +
*_mono_ns), units in field names, observation/interpretation split
(tests[] vs findings[]), columnar train evidence (nulls preserved per
index), the full v1 test-type registry, the anonymization logical types as
field notes, and a finding-requires-evidence invariant.

The one piece with real logic — §7.3 deterministic verdict derivation
(category = worst finding light; >50% failed/unsupported → inconclusive;
overall = worst category, inconclusive only if all are) — is implemented
in Verdicts and fully unit-tested. Document JSON round-trips (snake_case
wire names, null-in-columns), typed builders for train/traceroute/resolver
evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:24:20 +02:00
mrambossekandClaude Opus 5 3520eabd21 app: scaffold echolot-app + core-protocol — client spine verified live vs fmr
Multi-module Android app, built bottom-up from a verifiable core.
core-protocol is pure Kotlin/JVM (no Android SDK): SPKI-pinned control
plane (enroll/profile/session over HttpsURLConnection — API-1 compatible,
hostname verification off, trust is the pin), HKDF-SHA256 session keys,
ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
byte-compatible with the Go server.

Unit tests incl. the RFC 5869 HKDF vector (key derivation provably matches
the server). LiveServerTest + scripts/test-fmr.sh prove the client
end-to-end against the deployed fmr server: profile (8 caps), session,
ECHO rtt~11ms with the observation block returning our observed NAT port,
MTU 1400->1400, observations. Live test self-skips without ECHOLOT_LIVE_*.

Two client bugs caught live: java.net.http hostname verification (→
HttpsURLConnection, also the Android-minSdk-26 choice) and ECHO padding
needed for the observation to survive anti-amplification.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:16:41 +02:00
mrambossekandClaude Opus 5 b229eeb674 build-status: tls-echo/JA4 live on fmr — spec §4 complete
Cross-client verified (openssl vs python ssl yield distinct JA4s).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 21:01:19 +02:00
mrambossekandClaude Opus 5 1472a86508 server: tls-echo — ClientHello capture + JA4 on the TCP-echo port (§4 complete)
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 28s
server-release / release (push) Successful in 28s
A connection opening with a TLS handshake (first byte 0x16) and ALPN
elt-echo gets the ClientHello it sent back raw (b64) and as a JA4
fingerprint (sec.clienthello_echo), then a TLS byte-echo; plain
connections are unchanged. One port, multiplexed by a timed peek:
plain echo is server-speaks-first, so a silent client (peek timeout) is
greeted, while a TLS client's immediate ClientHello (0x16) routes to the
TLS path — 500ms tolerates ~1s RTT before misdetection.

JA4 (FoxIO): full ClientHello parser (ciphers, extensions, ALPN,
supported_versions, sig algs) with GREASE exclusion; a_b_c fingerprint,
unit-tested for structure + GREASE invariance. Live-verified: elt-echo
negotiated, JA4 t13d1712eo computed, 1530-byte ClientHello returned.
Capability tls-echo. This completes spec §4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:59:44 +02:00
mrambossekandClaude Opus 5 8a854141c5 build-status: server self-test live; fmr proven good (sysctl+MTU clean)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:49:05 +02:00
mrambossekandClaude Opus 5 d5e15816b5 server: fix egress-MTU probe — connect the socket before reading IP_MTU
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 27s
server-release / release (push) Successful in 27s
IP_MTU getsockopt returns ENOTCONN on an unconnected socket; the v0.3.4
probe set IP_MTU_DISCOVER and Sendto but never Connect'd, so every probe
errored. UDP-connect (no handshake) pins the route so IP_MTU reflects the
path; switched to Write (two return values). Sysctl audit already flagged
the four real fmr issues in v0.3.4; this makes the MTU proof report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:46:47 +02:00
mrambossekandClaude Opus 5 4ae744aae5 server: self-test — sysctl audit + egress-MTU self-proof ("server proven good")
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 27s
server-release / release (push) Successful in 28s
A measurement server must prove its own host isn't distorting results:
- sysctl audit (/proc/sys): flags accept_ra on a static host, ICMP
  redirects, ICMP rate-limiting of the server's own errors, and disabled
  TCP options — each a measurement-fidelity hazard, with the "why".
- egress-MTU self-proof: DF PMTUD probe (IP_MTU_DISCOVER + getsockopt
  IP_MTU, no root — Linux-only, stub elsewhere) to external anchors. If the
  server's own uplink is below 1500, client MTU tests measure THIS server,
  so we say so.
Exposed at GET /admin/selftest (full report) and as server_selftest
{mtu_ok, sysctl_ok} in the profile so clients can trust or skip MTU tests.
Recommended deploy/99-echolot-sysctl.conf + README section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:44:38 +02:00
mrambossekandClaude Opus 5 c9e0d06ea2 server: MTU probe (MTU_PROBE/MTU_ACK) — path-MTU / black-hole measurement
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 27s
server-release / release (push) Successful in 27s
Server ACKs each DF-flagged probe with a tiny MTU_ACK carrying the size it
received; the client binary-searches the path MTU. Non-amplifying by
construction. Tested.

Also records: v0.3.2 (http-echo + tls-reference) verified live on fmr, and
the finding that upstream trains are already observable via the
observations API (dedicated TRAIN_REPORT deferred — needs an
anti-amplification grant + columnar encoding).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:37:20 +02:00
mrambossekandClaude Opus 5 38fb73c34e server: HTTP echo + TLS reference (control-plane security measurements)
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 27s
server-release / release (push) Successful in 28s
- POST /v1/echo: returns the received request head + body (base64) and the
  observed TLS parameters (version, cipher, SNI, ALPN, resumed). The client
  diffs against what it sent to detect header injection/stripping,
  transparent proxying, or TLS interception (sec.http_echo). http-echo
  added to the capability set.
- GET /v1/tls-reference: the served leaf-first DER chain + pin, so the app
  can compare an out-of-band copy against its own handshake (sec.tls_reference).
  Always available, no auth — public handshake info.
- Optional CLEARTEXT http-echo listener (ECHOLOT_HTTP_ECHO_LISTEN, default
  off) exposing only /v1/echo for the plaintext-path tampering test.

Live-smoke-tested (HTTPS echo reflected an injected header + observed
TLS1.3; cleartext variant reports tls:none); httptest unit tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:34:03 +02:00
mrambossekandClaude Opus 5 379153219e build-status: canary DNS live on fmr — session attribution + 0x20 finding
Zone delegated + authoritative, verified via public recursion; per-session
nonce queries attributed in the observations API. First test caught
Google's 0x20 case randomization vs Cloudflare's plain case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:18:50 +02:00
158 changed files with 12923 additions and 42 deletions
+3
View File
@@ -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/
+24
View File
@@ -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`.
+301
View File
@@ -242,3 +242,304 @@ checksum-verified download v0.2.0→v0.3.0, atomic replace, restart — worked).
tcp-echo, stun-5780`.
Still not implemented: TLS-echo/JA4, HTTP echo, tls-reference, canary DNS (§6.1 reference
records), and the train/big-send/frag/throughput actions. Admin UI still token-mint + health only.
## Canary DNS live — server v0.3.1 on fmr (2026-07-31)
Zone `c.echo-lot.app` delegated (NS → fmr-1/fmr-2) and authoritative on all 4 service IPs
udp+tcp/53. Verified through full public recursion: `ttl-5` A→192.0.2.5 (Cloudflare), `ttl-3600`
AAAA→2001:db8::3600 (Google), `big-txt` TXT returned (TCP fallback, truncated over UDP as
designed). End-to-end session attribution works: a `<nonce>.<session-prefix>.c.echo-lot.app`
query resolved via a public resolver shows up in `GET /v1/sessions/{id}/observations` →
`dns_canary` with the resolver's real egress IP, transport, and EDNS. First real test already
caught a finding: **Google applies 0x20 case randomization** (mixed-case qname), Cloudflare does
not — captured via `case_preserved`. Capabilities now: udp-probe, delayed-echo, connect-back,
tcp-echo, stun-5780, canary-dns. Kept the hand-rolled stdlib DNS (no miekg/dns) — validated
against independent clients. Deployed via `--self-update` (v0.3.0→v0.3.1, checksum-verified).
## Server v0.3.2 + v0.3.3 (2026-07-31)
- **v0.3.2 — control-plane security (live on fmr, externally verified):** `POST /v1/echo`
reflects the received request head+body (b64) and observed TLS (version/cipher/SNI/ALPN) —
captured real SNI `fmr-1.echo-lot.app` and an injected header over public TLS1.3; `GET
/v1/tls-reference` returns the served DER chain + pin (cross-checked against the openssl-derived
pin). Optional cleartext echo listener (default off). Capability `http-echo`.
- **v0.3.3 — MTU probe (data plane):** MTU_PROBE (0x09) → small MTU_ACK (0x0A) carrying the
received datagram size; client DF-probes increasing sizes to find path MTU / black holes. ACK
is tiny → never amplifies. Tested.
- **Note on trains:** upstream trains (TRAIN_DATA 0x03) are already observable — every HMAC-valid
packet is recorded (seq/t_rx/size/type) with no per-packet response, so loss/reordering/inter-
arrival are visible via GET observations. The dedicated data-plane TRAIN_REPORT (0x05) is
deferred: §3.4 anti-amplification means it needs an asymmetric grant + columnar multi-datagram
encoding — a focused batch, not a corner to rush.
Remaining spec: tls-echo (ClientHello+JA4), TRAIN_REPORT, big/frag-send, throughput, downtrain;
real admin UI.
## Server self-test + host tuning — v0.3.4/v0.3.5, fmr proven good (2026-07-31)
The daemon now proves its own host is a clean measurement target:
- **sysctl audit** (`GET /admin/selftest`, startup warnings): on first run it flagged exactly 4
real issues on fmr — accept_ra=1 on a static-v6 host, accept_redirects=1, send_redirects=1,
icmp_ratelimit=1000. Recommended `server/deploy/99-echolot-sysctl.conf` applied (v6 default
route/addrs are proto static with 0 RA-derived routes, so disabling accept_ra is safe —
verified v6 egress intact after). Now sysctl_ok=true, 0 warnings.
- **egress-MTU self-proof**: DF PMTUD via IP_MTU_DISCOVER + getsockopt IP_MTU (v0.3.4 had a bug —
read IP_MTU without connecting → ENOTCONN; v0.3.5 connects first). fmr reports 1500 on both v4
and v6 → mtu_ok=true, so client MTU tests are trustworthy.
- Both signals ride in the profile as `server_selftest{mtu_ok,sysctl_ok}` so a client can skip
MTU testing when the server can't support it honestly.
fmr profile now: `{mtu_ok: true, sysctl_ok: true}`.
## Server v0.3.6 — tls-echo / JA4: spec §4 COMPLETE (2026-07-31)
The elt-echo TLS variant runs on the TCP-echo port (8441), multiplexed by a timed 0x16 peek
(plain echo stays server-speaks-first; a TLS ClientHello routes to the TLS path). It captures
the full ClientHello, returns it raw (b64) + as a JA4 fingerprint (FoxIO), then TLS byte-echoes —
the sec.clienthello_echo evidence. Hand-rolled ClientHello parser (ciphers/exts/ALPN/
supported_versions/sig-algs, GREASE-excluded), unit-tested. **Cross-client verified on fmr**:
openssl → t13d3013eo (30 ciphers), python ssl → t13d1712eo (17) — different stacks, different
fingerprints, correct _a structure both. Capability tls-echo.
Spec §4 (TCP/TLS/HTTP/STUN) is now fully implemented. Server capabilities: udp-probe,
delayed-echo, connect-back, http-echo, tcp-echo, tls-echo, stun-5780, canary-dns.
Remaining spec: §5 heavy actions (downtrain/big_send/frag_send/throughput) + the TRAIN_REPORT
retrieval path — all gated on the anti-amplification grant machinery (§3.4) — and a real admin UI.
## Production app — echolot-app/, core-protocol proven live (2026-07-31)
Started the Android client, bottom-up from the verifiable spine. `echolot-app/` is a multi-module
Gradle build; `core-protocol` is a **pure Kotlin/JVM** module (no Android SDK) implementing the
client half of probe-protocol.md: SPKI-pinned control plane (enroll/profile/session via
HttpsURLConnection — Android-API-1 compatible, hostname verification off since trust is the pin),
HKDF-SHA256 session keys, and the ELT1 UDP data plane (HMAC gate, ECHO+observation, MTU probe) —
byte-compatible with the Go server. Unit tests pass incl. the RFC 5869 HKDF vector (so key
derivation provably matches the server). **Verified END-TO-END against live fmr** via
`scripts/test-fmr.sh` (mint token over SSH → enroll on public control plane → run LiveServerTest):
profile (8 caps), session, ECHO rtt ~11ms with the observation block round-tripping the client's
observed NAT port, MTU probe 1400→1400, observations 298B. Two client bugs found+fixed doing it:
java.net.http did hostname verification (switched to HttpsURLConnection) and ECHO needed ≥72-byte
requests for the full 40-byte observation to survive §3.4 anti-amplification. Next: core-measurement
(schema types), core-probe (port prober probes), core-shizuku (dual-path), Compose UI.
## App: core-measurement + core-engine — full server-facing vertical proven (2026-07-31)
Two more pure-Kotlin/JVM modules, both verifiable without a device:
- **core-measurement**: the measurement-schema.md document model (two-clock, columnar trains,
test-type registry, anonymization types, finding-requires-evidence). The §7.3 deterministic
verdict derivation is implemented + unit-tested; document JSON round-trips.
- **core-engine**: the run engine composing core-protocol probes into core-measurement documents.
Injected clock/UUID source (pure, testable). Runs a server ECHO train → RTT distribution, loss,
and NAT-rebinding detection (from the server's observed source port) as train.udp_updown.
**Verified END-TO-END against fmr**: 20-packet train, 0% loss, RTT 1.7/2.5/6.9ms, single
observed port (no rebinding) → valid MeasurementDocument (2.3kB), overall GREEN.
So the whole server-facing stack — protocol client → engine → schema document → verdict — is now
proven against the live server, no device needed. Next modules (core-probe device-tier,
core-shizuku dual-path, Compose app) are Android + need on-device verification.
## App: installable APK — core-probe + Compose UI (2026-07-31)
The production Android app assembles. Android toolchain in echolot-app mirrors the prober (AGP
9.2 built-in Kotlin — do NOT also apply kotlin.android, it double-registers the `kotlin`
extension; that was the one build gotcha). Modules added:
- **core-probe** (Android lib): Probe→core-measurement Test abstraction; NetworkInventory
(LinkProperties → measurement networks[]), LinkSnapshotProbe (link.snapshot), IcmpProbe
(per-network icmp.ping4/6, ported from the prober's validated per-network logic).
- **app** (Compose): RunViewModel orchestrates probes → assembles a MeasurementDocument with a
§7.3 summary + first-pass findings; Compose UI shows overall/ per-category traffic lights,
networks, tests (status/metrics), findings; JSON export via share intent. Survives rotation
(ViewModel). App-tier only for now; server-facing (core-engine) and Shizuku tier are additive
follow-ups (app degrades gracefully without them, like the prober).
Debug APK: 9.5 MB, `echolot-app/app/build/outputs/apk/debug/app-debug.apk`. Not yet run on device
(needs the user's phone). core-shizuku (dual-path executor) deferred as additive.
## App verified on-device — both phones (2026-07-31)
The production Echolot app runs on real hardware, on BOTH devices, via the beacon-managed adb:
- OnePlus 15 (CPH2747, A16) and Lenovo TB330FU (A15): link.snapshot OK, icmp.ping4 OK
(per-network, RTT ~40ms), icmp.ping6 FAILED → finding "No IPv6 ICMP path on any active network"
→ category ipv6 yellow → **Overall YELLOW**. The verdict is driven by the real broken-LAN IPv6
(RA default route, no global prefix) we first found with the prober — the product now surfaces
it end to end (probe → schema → verdict → traffic-light UI).
Wireless-adb beacon (tools/adb-beacon) made this practical: both devices self-report their
rotating wireless-debug port to fmr:443; a PC connector keeps adb connected. Debugged live
against the restricted LAN (cleartext policy, egress filtering, shared-LAN mDNS crossing, fast
port rotation) — all handled.
## App: core-shizuku (shell tier) built + wired (2026-07-31)
Ported the prober's validated Shizuku executor into the app as a library module:
- AIDL IUserService + UserService (runs `sh -c` as shell/root in the Shizuku-spawned process),
Shizuku provider merged into the app manifest.
- **ShizukuRunner: the build-4 dual-path executor** — bind the UserService (25s + retry) where it
works (OnePlus 7/7), fall back to the legacy `Shizuku.newProcess` reflection API where it never
binds (Lenovo). `exec_path` records which path ran.
- ShizukuProbe: runs the shell battery (ip neigh / ip -6 route / ip addr / ip monitor /
network_stack DHCP / wifi dump) and emits a shizuku-tier `link.ip_monitor` Test with the raw
per-device dumps as evidence + commands_ok/exec_path metrics. Self-degrades to UNSUPPORTED when
Shizuku isn't running.
Wired into RunViewModel (runs after app-tier probes; sets tiers.shizuku). App APK assembles clean.
On-device test deferred: at build time no device was reachable (tablet wifi/beacon dropped on the
churning LAN; phone wireless debugging disabled to stop reconnect notifications). Will verify on a
device later — expecting UserService on the OnePlus, newProcess fallback on the Lenovo, per the
prober.
## App on-device: net.captive_portal verified, Shizuku degrades correctly (2026-07-31)
Installed the app (core-shizuku + net.captive_portal) on the OnePlus 15 and ran it; report archived
at `echolot-app/reports/CPH2747-app-run1.json`. Results:
- **net.captive_portal works** — Android's NetworkMonitor logic reproduced: default + wifi both
returned HTTP **204** on the HTTPS *and* HTTP generate_204 probes → `validated`; **cellular
returned neither (-1/-1) → `no_internet`**. The per-network split immediately surfaces an
asymmetry the OS hides (wifi validated, cellular can't reach the checks at all).
- **Shizuku tier degrades correctly**: `binder_alive:false` → test UNSUPPORTED, `tiers.shizuku:false`
(Shizuku isn't running on the phone). The dual-path *executing* path still needs an on-device
test with Shizuku started (expect UserService on this OnePlus).
- 5 tests now: link.snapshot ok, icmp.ping4 ok, icmp.ping6 failed, net.captive_portal ok,
link.ip_monitor(shizuku) unsupported → overall YELLOW via the IPv6 finding.
Also fixed this session: the beacon app itself caused the "wireless debugging connected"
notification spam (it re-resolved adbd's own mDNS advertisement, making adbd re-arm each time);
now resolves once per service instance and the heartbeat re-POSTs the cached port only.
## App: dns.canary verified against the live server (2026-08-01)
Built the client half of the canary-DNS measurement and verified it on the OnePlus against the
deployed fmr zone (`echolot-app/reports/CPH2747-app-run2-dns.json`):
- All four spec-frozen reference records matched byte-for-byte through the network's own resolver
(ttl-5→192.0.2.5, ttl-60→192.0.2.60, ttl-3600→192.0.2.36, ttl-86400→192.0.2.86) → nothing on
this path rewrites DNS answers (`dns.answer_integrity` in the green case).
- The un-cacheable nonce name `1006ad16.adhoc.c.echo-lot.app` resolved to 192.0.2.21 →
`reached_authoritative: true`, proving the query actually reached the canary server rather than
being answered from a cache or an interceptor.
Findings wired: `dns.answer_rewritten` (high) when a reference mismatches, and
`dns.authoritative_unreachable` (medium) when the nonce isn't answered by the canary server.
This closes the first full client↔server measurement loop: the Kotlin app measures against the Go
server's canary zone on real hardware. 6 tests now run per measurement.
## App: Shizuku shell tier VERIFIED on-device — the app is feature-complete for v1 tiers (2026-08-01)
Ran the app on the OnePlus with Shizuku running (`echolot-app/reports/CPH2747-app-run3-shizuku.json`):
- **`tiers: {app:true, shizuku:true}`**, shizuku test **ok**, **commands_ok 7/7**,
**`exec_path: UserService`** (the OnePlus binds it — matches the prober; the newProcess fallback
stays for the Lenovo).
- Evidence is the real privileged material the production parsers need: live ARP/NDP neighbor
table, per-table IPv6 routes, `ip addr`, an actual `[NEIGH]` netlink event from `ip monitor`,
IpClient DHCP logs incl. APF capabilities, and the wifi state dump — all as shell(2000).
Both privilege tiers now work end to end in the production app on real hardware, alongside the
canary-DNS loop against the live server. 6 tests/run: link.snapshot, icmp.ping4, icmp.ping6,
net.captive_portal, dns.canary, link.ip_monitor(shizuku).
## App: nat.stun_5780 — NAT behavior discovery verified against the live server (2026-08-01)
Client-side RFC 5389/5780 STUN (hand-rolled, stdlib only) exercising the server's `stun-5780`
capability. Three binding requests from ONE socket: primary, the server's OTHER-ADDRESS
(alternate IP), and CHANGE-REQUEST(port). Verified on the OnePlus
(`echolot-app/reports/CPH2747-app-run4-stun.json`):
- local `10.13.102.124` → mapped `178.191.120.247:53259`, `behind_nat: true`
- `other_address 89.185.109.151:3479` — the server's second IP answered, so RFC 5780 works
end to end (client ↔ our own STUN implementation)
- **mapping: endpoint-independent** (same external port toward a different destination → P2P
friendly); **filtering: address/port-dependent** (no reply to CHANGE-REQUEST → unsolicited
inbound is dropped). Classic full-cone-mapping + port-restricted-filtering NAT.
Finding wired: `nat.symmetric` (medium) when mapping is address/port-dependent.
Two bugs caught by running it for real: port preservation was misread as "no NAT" (now compares
ADDRESSES), and an unbound socket reports the wildcard as its local address (now resolved via a
throwaway connected socket). 7 tests/run.
## App: autorun mode + IPv6 severity rework (2026-08-01)
**IPv6 is no longer treated as a defect just for being absent.** The finding now depends on
whether the network actually provisioned IPv6 (a global v6 address or a `::/0` route):
- not provisioned → `ipv6.not_offered`, severity **INFO → green**. Most networks are still
IPv4-only and that is not a fault.
- provisioned but ICMPv6 fails → `ipv6.broken`, severity **MEDIUM → yellow**. Half-configured
IPv6 is worse than none (Happy-Eyeballs stalls). Verified on the OnePlus: our LAN advertises a
v6 default route with no working path, so it correctly reports `ipv6.broken`.
**Autorun mode** — one adb command runs a full measurement unattended and collects the result
without any UI tapping or adb round-trip:
```
adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
curl http://<fmr>/reports # list
curl http://<fmr>/report/<name> # fetch
```
The app runs the suite, POSTs the MeasurementDocument to the collection endpoint (receiver.py
gained `POST /report`, `GET /reports`, `GET /report/<name>`), shows the result for 3 s, then
finishes itself — leaving the device as it was found. On upload failure it stays open so the
error is visible. Grant permissions once via `adb shell pm grant app.echo_lot.app
android.permission.ACCESS_FINE_LOCATION` so nothing blocks on a dialog.
## App: router identification, brand icons, DEV build variant (2026-08-01)
**`link.ra_source` — who is advertising IPv6 here, and what box is it?** New app-tier probe
(registry addition). Identification chain, each step recorded as evidence so nothing is guessed:
1. RA source = next-hop of the `::/0` route per network (a `fe80::` link-local).
2. **MAC recovered from the modified-EUI-64 link-local** (strip `ff:fe`, flip the U/L bit) —
e.g. `fe80::7a9a:18ff:fe54:b8f9` → `78:9a:18:54:b8:f9`. RFC 7217/privacy addresses don't encode
a MAC and are reported as such rather than guessed.
3. Vendor via a curated OUI table (`Oui.kt` — SOHO/router vendors; unknown OUIs are printed
verbatim). Locally-administered (randomized) MACs are flagged.
4. **UPnP/SSDP M-SEARCH** → the gateway's `SERVER:` banner + device-description XML gives
manufacturer / model / friendly name. This is what usually names the exact box.
5. Reverse DNS for both gateways.
All SSDP responders are recorded (not just the gateway) so a rogue RA sender that isn't the
gateway can still be matched — and the MAC travels with every identity source, which is the hook
for the future LLDP / mDNS cross-matching.
UI: a "Router / IPv6 advertiser" panel above the network list, leading with the identified
vendor/model.
**Icons + DEV variant.** The branding adaptive icon is now the app icon: `icon-adaptive-*.svg`
converted to Android vector drawables (SVG transform baked in, gradient background, monochrome
layer for themed icons) plus PNG mipmaps for legacy launchers. The **debug build is a separate
app**: `applicationIdSuffix .dev`, label "Echolot DEV", and a DEV-badged icon (layer-list =
production foreground + generated amber DEV ribbon) so it is unmistakable next to a real install
and both can be installed side by side.
NOTE for tooling: the dev package is `app.echo_lot.app.dev`, activity `app.echo_lot.app.MainActivity`.
### link.ra_source verified on-device — it named the actual router (2026-08-01)
Run archived at `echolot-app/reports/CPH2747-app-run5-router-id.json`. On the wifi network the
probe identified the RA sender completely, from an unprivileged app:
- RA source `fe80::7a9a:18ff:fe54:b8f9` → **MAC 78:9A:18:54:B8:F9 recovered via EUI-64**
(matches the Shizuku neighbor table exactly) → vendor **MikroTik** by OUI
- IPv4 gateway `10.13.102.1`, reverse DNS `router.hudelist.local`
- UPnP: `RouterOS/7.23.2 UPnP/1.0 MikroTik` → manufacturer MikroTik, model Router OS,
friendly name "MikroTik Router"
So the box advertising this LAN's broken IPv6 RA is a **MikroTik running RouterOS 7.23.2**, named
by two independent methods (OUI from the address itself + UPnP device description) that corroborate.
On cellular the carrier's RA source is an RFC 7217 privacy address and is correctly reported as
"not EUI-64" rather than guessed.
The SSDP sweep also inventoried the LAN (Synology DS1522+ DSM 7.3, a Sky ES160 gateway) — the
raw material for the planned LLDP/mDNS cross-matching by MAC.
Fixes from this run: added the confirmed MikroTik OUI 78:9A:18 (+ other RouterBOARD ranges), and
an elvis-operator bug that printed "no UPnP response" even when UPnP data was present.
### App UX: progress bar + ETA, cancel, and edge-to-edge insets (2026-08-01)
- **Progress + ETA**: `Probe.estimatedMs` (per-probe, from measured on-device durations — the
timeout-bound probes dominate: icmp.ping6 ~7s on a v4-only net, captive-portal ~9s, SSDP ~7s,
STUN ~6s) drives a determinate bar plus "test N of M · ~Xs left". The Shizuku battery is counted
in the total so the bar covers the whole run.
- **Cancel**: stops an in-flight run and shows what was measured so far, assembled into a normal
document (findings + verdict over the partial set). Deliberately **does not upload** — a partial
run is for the person looking at the screen, not for the record.
- **Insets/cutout**: Android 15 draws edge-to-edge by default, so the title was running under the
status-bar clock and the camera cutout. The root column now uses `safeDrawingPadding()`, which
covers status bar, navigation bar and display cutout.
### Shell-tier readiness shown before a run (2026-08-01)
`ShizukuAvailability` distinguishes four states and the UI only speaks when it is actionable:
- **NOT_INSTALLED → says nothing.** Users who don't use Shizuku are never nagged.
- **INSTALLED_NOT_RUNNING → amber banner** "Shizuku is installed but not running — start it to
include shell-tier tests". This is the case worth reminding about: the user has it, but a
stopped service silently costs them the whole shell tier.
- NEEDS_PERMISSION → "running but not authorised, it will ask on first use".
- READY → green "shell-tier tests will run".
Detection is listener-based (`addBinderReceivedListenerSticky` + binder-dead), because
`pingBinder()` is only truthful once ShizukuProvider has delivered the binder — a one-shot poll at
launch would show a false "not running". Installed-vs-not needs the `<queries>` package-visibility
entry on Android 11+. Verified on-device: with shizuku_server stopped the banner appears correctly.
### Shizuku banner is actionable; progress + cancel verified on-device (2026-08-01)
Tapping the shell-tier banner now does the right thing per state: **installed-but-stopped** →
deep-links into the Shizuku app (a third-party app *cannot* start Shizuku itself; the wireless-
debugging pairing flow is privileged and lives in that app, so taking the user there in one tap is
the best available), **running-but-unauthorised** → fires the Shizuku permission request directly.
The hint line states which action the tap performs.
Verified on-device in one screenshot: progress bar at "test 4 of 8 · icmp.ping6 · ~33s left",
Cancel button beside the disabled Run button, title clear of the status bar/cutout, and the banner
having live-switched from "installed but not running" to "running but not authorised" via the
binder listener when Shizuku was started mid-session.
### Why the Shizuku banner can't start wireless debugging directly (verified, 2026-08-01)
Checked against Shizuku 13.6's own manifest (pulled the APK, `aapt2 dump xmltree`): the
wireless-debugging entry points — `moe.shizuku.manager.adb.AdbPairingTutorialActivity`,
`moe.shizuku.manager.adb.AdbPairingService`, `moe.shizuku.manager.starter.StarterActivity` —
declare **no intent filters**, so they are not exported and a third-party app cannot launch them.
`MainActivity` is the only reachable entry and answers MAIN/LAUNCHER only (no deep link), which is
why the handoff lands on the screen whose primary action is the root start.
Best available behavior, now implemented: the banner still opens Shizuku, but the hint names the
exact steps there ("Pairing", then "Start"), and a second tap target opens **Developer options**
(`Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS` — public and exported) since Wireless
debugging must be enabled first for Shizuku's wireless start to work at all.
+6
View File
@@ -0,0 +1,6 @@
.gradle/
build/
/local.properties
/.idea/
*.iml
.DS_Store
+35
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
/build
+39
View File
@@ -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)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+66
View File
@@ -0,0 +1,66 @@
// 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 = 2
versionName = "0.2.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(project(":core-privacy"))
implementation(project(":core-archive"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
debugImplementation(libs.androidx.ui.tooling)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

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,107 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.echo_lot.archive.ArchivedRun
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Archived runs, newest first.
*
* Each row states plainly whether the run left the device, because "is this backed up / did I
* share this?" is the question a history list actually gets asked.
*/
@Composable
fun HistoryScreen(
runs: List<ArchivedRun>,
status: String?,
onOpen: (String) -> Unit,
onUpload: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("History", style = MaterialTheme.typography.titleLarge)
}
status?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
if (runs.isEmpty()) {
Text(
"No archived runs yet. Finished runs are kept here automatically unless you turn " +
"archiving off in settings.",
style = MaterialTheme.typography.bodyMedium,
)
return@Column
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(runs, key = { it.id }) { r ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
r.verdict?.uppercase() ?: "",
color = verdictTint(r.verdict),
style = MaterialTheme.typography.titleMedium,
)
Text(
" " + humanTime(r.savedAtEpochMs),
style = MaterialTheme.typography.bodyMedium,
)
}
Text(
"${r.findingCount} finding(s) · ${r.sizeBytes / 1024} kB · ${r.anonymization}",
style = MaterialTheme.typography.bodySmall,
)
Text(
if (r.uploaded) "uploaded to ${r.uploadedTo ?: "a server"}"
else "on this device only",
style = MaterialTheme.typography.bodySmall,
color = if (r.uploaded) Color(0xFF7FD17F) else Color(0xFFBBBBBB),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onOpen(r.id) }) { Text("Export") }
TextButton(onClick = { onUpload(r.id) }) {
Text(if (r.uploaded) "Upload again" else "Upload")
}
TextButton(onClick = { onDelete(r.id) }) { Text("Delete") }
}
}
}
}
}
}
}
private fun verdictTint(v: String?): Color = when (v?.lowercase()) {
"green", "ok", "pass" -> Color(0xFF7FD17F)
"yellow", "warn" -> Color(0xFFE0C060)
"red", "fail" -> Color(0xFFE07070)
else -> Color(0xFFBBBBBB)
}
private val stamp: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
private fun humanTime(epochMs: Long): String = stamp.format(Instant.ofEpochMilli(epochMs))
@@ -0,0 +1,428 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import androidx.lifecycle.viewmodel.compose.viewModel
import app.echo_lot.measurement.*
/** The app's three top-level screens. */
private enum class Screen { RUN, HISTORY, SETTINGS }
class MainActivity : ComponentActivity() {
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestRuntimePermissions()
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.background) {
val vm: RunViewModel = viewModel()
// Three flat screens, so a plain state variable beats a navigation library:
// there is no back stack to model beyond "return to the run screen".
var screen by remember { mutableStateOf(Screen.RUN) }
var preview by remember { mutableStateOf<String?>(null) }
// Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// starts a run immediately and uploads the report, so an unattended
// measurement needs no UI tapping and no adb round-trip to collect.
val autorun = intent?.getBooleanExtra("autorun", false) == true
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(devUpload = true)
}
// In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it
// was found. On failure it stays open so the error is visible.
val st = vm.state
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
if (autorun && !st.running && st.document != null &&
st.uploadStatus?.startsWith("uploaded") == true
) {
kotlinx.coroutines.delay(3000)
finish()
}
}
when (screen) {
Screen.SETTINGS -> SettingsScreen(
settings = vm.settings,
archivedRuns = vm.state.history.size,
archivedBytes = vm.archivedBytes(),
onApplyRetention = vm::applyRetention,
onDeleteAll = vm::deleteAllRuns,
onPreviewUpload = {
// Preview the newest run, since that is the one the user just made
// and the one they are deciding about.
vm.state.history.firstOrNull()?.let { r ->
lifecycleScope.launch { preview = vm.uploadPreview(r.id) }
}
},
onBack = { screen = Screen.RUN },
)
Screen.HISTORY -> HistoryScreen(
runs = vm.state.history,
status = vm.state.archiveStatus,
onOpen = { id ->
lifecycleScope.launch {
vm.readRun(id)?.let { text ->
startActivity(
Intent.createChooser(
Report.shareJson(this@MainActivity, id, text),
"Export Echolot run",
)
)
}
}
},
onUpload = vm::uploadRun,
onDelete = vm::deleteRun,
onBack = { screen = Screen.RUN },
)
Screen.RUN -> EcholotScreen(
state = vm.state,
onRun = { vm.run() },
onCancel = vm::cancel,
onDeveloperOptions = {
runCatching {
startActivity(
app.echo_lot.shizuku.ShizukuAvailability.developerOptionsIntent()
)
}
},
onShizukuAction = {
// Shizuku can only be started from its own app (the pairing flow lives
// there), so send the user straight to it; if it is already running we
// just need permission.
when (vm.state.shizukuState) {
app.echo_lot.shizuku.ShizukuAvailability.State.NEEDS_PERMISSION ->
app.echo_lot.shizuku.ShizukuAvailability.requestPermission()
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING ->
app.echo_lot.shizuku.ShizukuAvailability.launchIntent(this)
?.let { startActivity(it) }
else -> Unit
}
},
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
onOpenSettings = { screen = Screen.SETTINGS },
onOpenHistory = { vm.refreshHistory(); screen = Screen.HISTORY },
)
}
preview?.let { text ->
UploadPreviewDialog(text) { preview = null }
}
}
}
}
}
private fun requestRuntimePermissions() {
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
val missing = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
}
}
private fun verdictColor(v: Verdict): Color = when (v) {
Verdict.GREEN -> Color(0xFF2E7D32)
Verdict.YELLOW -> Color(0xFFF9A825)
Verdict.RED -> Color(0xFFC62828)
Verdict.INCONCLUSIVE -> Color(0xFF616161)
}
private fun statusColor(s: TestStatus): Color = when (s) {
TestStatus.OK -> Color(0xFF66BB6A)
TestStatus.PARTIAL -> Color(0xFFFFB300)
TestStatus.FAILED -> Color(0xFFEF5350)
TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E)
}
@Composable
private fun EcholotScreen(
state: UiState,
onRun: () -> Unit,
onCancel: () -> Unit,
onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit,
onExport: (MeasurementDocument) -> Unit,
onOpenSettings: () -> Unit,
onOpenHistory: () -> Unit,
) {
Column(
Modifier
.fillMaxSize()
// Android 15 draws edge-to-edge by default: without this the title runs under the
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
// AND the display cutout, so text never lands where it can't be read.
.safeDrawingPadding()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess",
color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
}
TextButton(onClick = onOpenHistory) { Text("History") }
TextButton(onClick = onOpenSettings) { Text("Settings") }
}
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
// only users who actually use it get reminded that it must be running.
state.shizukuNotice?.let { notice ->
Card(
Modifier.fillMaxWidth().let { m ->
if (state.shizukuHint != null) m.clickable { onShizukuAction() } else m
},
colors = CardDefaults.cardColors(
containerColor = if (state.shizukuReady) Color(0xFF14301F) else Color(0xFF3A2E12),
),
) {
Column(Modifier.padding(10.dp)) {
Text(
notice, fontSize = 12.sp,
color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A),
)
state.shizukuHint?.let {
Text(it, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
color = Color(0xFFFFB454))
}
// Wireless debugging must be ON before Shizuku's wireless start works, and
// that screen (unlike Shizuku's pairing activity) is publicly launchable.
if (state.shizukuState ==
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING
) {
Text(
"Wireless debugging settings →",
Modifier.padding(top = 6.dp).clickable { onDeveloperOptions() },
fontSize = 11.sp, color = Color(0xFF35E0C4),
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onRun, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
}
if (state.running) {
OutlinedButton(onClick = onCancel) { Text("Cancel") }
}
state.document?.let { doc ->
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
}
}
state.archiveStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
state.uploadStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.running) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val frac = if (state.stepsTotal > 0)
state.stepsDone.toFloat() / state.stepsTotal else 0f
LinearProgressIndicator(
progress = { frac },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
if (state.stepsTotal > 0)
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
)
Text(state.currentStep ?: "", fontSize = 12.sp,
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
if (state.etaSeconds > 0) {
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
state.document?.let { doc -> Results(doc) }
}
}
@Composable
private fun Results(doc: MeasurementDocument) {
val summary = doc.summary
if (summary != null) {
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
}
FlowCategories(summary.categories)
}
RouterPanel(doc)
SectionTitle("Networks (${doc.networks.size})")
for (n in doc.networks) {
Text("${n.transport.name.lowercase()} ${n.iface ?: ""}" +
n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" },
fontSize = 13.sp, fontFamily = FontFamily.Monospace)
}
SectionTitle("Tests (${doc.tests.size})")
for (t in doc.tests) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Dot(statusColor(t.status))
Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp)
}
val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000
Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
if (doc.findings.isNotEmpty()) {
SectionTitle("Findings (${doc.findings.size})")
for (f in doc.findings) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(f.title, fontWeight = FontWeight.Medium)
Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(f.description, fontSize = 12.sp)
}
}
}
}
}
/**
* Who advertises IPv6 here, and what box is it? Pulled from the link.ra_source evidence and shown
* up front — a rogue/misconfigured RA sender is a top cause of broken IPv6, and "some router" is
* not actionable without an identity.
*/
@Composable
private fun RouterPanel(doc: MeasurementDocument) {
val test = doc.tests.firstOrNull { it.type == TestType.LINK_RA_SOURCE } ?: return
val nets = (test.evidence?.get("networks") as? kotlinx.serialization.json.JsonArray) ?: return
if (nets.isEmpty()) return
SectionTitle("Router / IPv6 advertiser")
for (el in nets) {
val o = el as? kotlinx.serialization.json.JsonObject ?: continue
fun f(k: String): String? =
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val ra = f("ra_source")
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(f("network") ?: "network", fontWeight = FontWeight.Medium)
// The identity line: vendor/model if we could pin it down.
val identity = listOfNotNull(
f("upnp_manufacturer"), f("upnp_model"), f("ra_source_vendor"),
).distinct().joinToString(" · ").ifBlank { null }
identity?.let {
Text(it, fontWeight = FontWeight.SemiBold, color = Color(0xFF35E0C4), fontSize = 15.sp)
}
f("upnp_friendly_name")?.let { Row0("name", it) }
ra?.let { Row0("RA source", it) }
f("ra_source_mac")?.let { Row0("RA source MAC", it) }
f("ra_source_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("reverse DNS", it) }
f("v4_gateway")?.let { Row0("IPv4 gateway", it) }
f("v4_gateway_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("gateway rDNS", it) }
f("upnp_server")?.let { Row0("UPnP server", it) }
}
}
}
}
@Composable
private fun Row0(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$label:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
}
}
@Composable
private fun FlowCategories(categories: Map<String, CategorySummary>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
for ((name, cat) in categories) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Dot(verdictColor(cat.verdict))
Text(name, modifier = Modifier.weight(1f))
Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "",
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun Dot(color: Color) {
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
}
/**
* Shows the exact JSON an upload would send.
*
* This exists because an anonymizer the user cannot inspect is just a promise. Being able to
* read the outgoing document — and find their own SSID absent from it — is what makes the
* privacy setting checkable rather than merely stated.
*/
@Composable
private fun UploadPreviewDialog(text: String, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
title = { Text("This is what would be uploaded") },
text = {
Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) {
Text(text, fontSize = 10.sp, fontFamily = FontFamily.Monospace)
}
},
)
}
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import app.echo_lot.measurement.MeasurementDocument
import kotlinx.serialization.json.Json
import java.io.File
/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */
object Report {
private val json = Json { prettyPrint = true; encodeDefaults = true }
fun toJson(doc: MeasurementDocument): String =
json.encodeToString(MeasurementDocument.serializer(), doc)
fun share(ctx: Context, doc: MeasurementDocument): Intent =
shareJson(ctx, doc.run.id, toJson(doc))
/**
* Shares an already-serialized run — an archived one, whose bytes must go out exactly as
* stored rather than being re-serialized through the model (which would silently drop
* anything a newer schema version added).
*/
fun shareJson(ctx: Context, runId: String, json: String): Intent {
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
val file = File(dir, "echolot-run-$runId.json")
file.writeText(json)
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
return Intent(Intent.ACTION_SEND).apply {
type = "application/json"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
}
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import app.echo_lot.measurement.MeasurementDocument
import java.net.HttpURLConnection
import java.net.URL
/**
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
*/
object ReportUploader {
data class Result(val ok: Boolean, val detail: String)
fun upload(doc: MeasurementDocument): Result {
val url = BuildConfig.REPORT_UPLOAD_URL
if (url.isBlank()) return Result(false, "no upload URL configured")
return try {
val body = Report.toJson(doc).toByteArray()
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10_000
readTimeout = 15_000
doOutput = true
setRequestProperty("Content-Type", "application/json")
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
}
}
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() } ?: ""
conn.disconnect()
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
} catch (t: Throwable) {
Result(false, t.message ?: t.javaClass.simpleName)
}
}
}
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import app.echo_lot.archive.ArchivedRun
import app.echo_lot.archive.RunArchive
import app.echo_lot.measurement.MeasurementDocument
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import java.io.File
import java.security.SecureRandom
/**
* Ties together the three things that happen to a finished run: it gets archived, it may get
* anonymized, and it may get uploaded — in that order, and with the archive always holding the
* *unredacted* document.
*
* That ordering is the important decision. The local archive is the user's own data on their own
* device, and redacting it would destroy exactly the detail that makes a week-old run worth
* keeping; the anonymizer exists for the moment data crosses to someone else's machine. So
* redaction happens on the way out, per upload, and the archive is never the lossy copy.
*/
class RunStore(context: Context, private val settings: Settings) {
private val archive = RunArchive(File(context.filesDir, "runs"))
private val json = Json { encodeDefaults = true; explicitNulls = true }
fun list(): List<ArchivedRun> = archive.list()
fun read(id: String): String? = archive.read(id)
fun delete(id: String) = archive.delete(id)
fun deleteAll(): Int = archive.deleteAll()
fun totalBytes(): Long = archive.totalBytes()
/** Archives a finished run under the user's retention policy. Null when archiving is off. */
fun archive(doc: MeasurementDocument): ArchivedRun? =
archive.save(Report.toJson(doc), settings.retention())
/** Applies retention now — e.g. after the user tightens the limits in settings. */
fun purgeNow() = archive.purge(settings.retention())
/**
* Produces exactly the bytes an upload would send, so the UI can show the user their own
* document as the server will see it *before* it goes. "Preview what you're about to share"
* is the only honest way to present an anonymizer: its correctness is not something a user
* should have to take on faith.
*/
fun redactedForUpload(docJson: String, level: PrivacyLevel = settings.privacyLevel): String {
val parsed = runCatching { json.parseToJsonElement(docJson).jsonObject }.getOrNull()
?: return docJson
return json.encodeToString(JsonObject.serializer(), Anonymizer(level, salt()).anonymize(parsed))
}
private fun salt(): Salt =
if (settings.stableSalt) Salt.stable(settings.saltSecret())
else Salt.perRun(ByteArray(32).also { SecureRandom().nextBytes(it) })
sealed interface UploadOutcome {
data class Sent(val serverName: String, val detail: String) : UploadOutcome
/** The operator's policy says no. Not retryable, and not the user's fault. */
data class Refused(val reason: String) : UploadOutcome
data class Failed(val detail: String) : UploadOutcome
data object NotConfigured : UploadOutcome
}
/**
* Uploads one archived run to the configured server, redacting first.
*
* The server's advertised minimum wins over the user's preference when it is stricter — a
* server may demand more anonymization than the user chose, never less. Blocking; callers
* run it off the main thread.
*/
fun upload(runId: String): UploadOutcome {
if (!settings.serverConfigured) return UploadOutcome.NotConfigured
val docJson = read(runId) ?: return UploadOutcome.Failed("run $runId is not in the archive")
return try {
val client = ControlClient(settings.serverUrl, setOf(settings.serverPin))
val profile = client.profile(settings.serverCredential)
profile.uploads.refusalReason()?.let { return UploadOutcome.Refused(it) }
val level = PrivacyLevel.max(
settings.privacyLevel,
PrivacyLevel.fromWire(profile.uploads.minAnonymization),
)
val body = redactedForUpload(docJson, level)
val reply = client.uploadRun(settings.serverCredential, body)
archive.markUploaded(runId, profile.name)
UploadOutcome.Sent(profile.name, "as $level, ${body.toByteArray().size} bytes: ${reply.take(120)}")
} catch (e: UploadRefused) {
UploadOutcome.Refused(e.message ?: "refused by the server")
} catch (t: Throwable) {
UploadOutcome.Failed(t.message ?: t.javaClass.simpleName)
}
}
}
@@ -0,0 +1,432 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.app.Application
import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.echo_lot.measurement.*
import app.echo_lot.probe.CaptivePortalProbe
import app.echo_lot.probe.DnsCanaryProbe
import app.echo_lot.probe.IcmpProbe
import app.echo_lot.probe.LinkSnapshotProbe
import app.echo_lot.probe.StunProbe
import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds
import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuAvailability
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import java.util.UUID
data class UiState(
val running: Boolean = false,
val currentStep: String? = null,
val document: MeasurementDocument? = null,
/** Result line of an automated upload (autorun mode); null when not attempted. */
val uploadStatus: String? = null,
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
/** Where the finished run went: archived locally, uploaded, or neither (and why). */
val archiveStatus: String? = null,
/** History, newest first. Refreshed after every run and whenever the history screen opens. */
val history: List<app.echo_lot.archive.ArchivedRun> = emptyList(),
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
val shizukuNotice: String? = null,
val shizukuReady: Boolean = false,
val shizukuHint: String? = null,
val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED,
)
/**
* Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
* survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a
* follow-up once enrollment UI lands.
*/
class RunViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState())
private set
val settings = Settings(app)
private val store = RunStore(app, settings)
private var runJob: kotlinx.coroutines.Job? = null
private val stopShizukuObserver: () -> Unit
init {
// Report shell-tier readiness up front. The binder arrives asynchronously, so this is a
// listener, not a one-shot poll — otherwise a running Shizuku would look "not running"
// for the first moment after launch.
stopShizukuObserver = ShizukuAvailability.observe(app) { st ->
state = state.copy(
shizukuNotice = ShizukuAvailability.describe(st),
shizukuReady = st == ShizukuAvailability.State.READY,
shizukuHint = ShizukuAvailability.actionHint(st),
shizukuState = st,
)
}
}
override fun onCleared() {
stopShizukuObserver()
super.onCleared()
}
// Results collected so far. A cancelled run must still be able to show what it measured.
private val collected = mutableListOf<Test>()
private var runIds: RunIds = RunIds()
private var runStartWall: String = ""
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
private var runShizukuOk = false
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds {
val originNanos = System.nanoTime()
override fun uuid(): String = UUID.randomUUID().toString()
override fun monoNs(): Long = System.nanoTime() - originNanos
}
/**
* Runs one measurement, then archives it and — if the user has turned that on — uploads it.
*
* [devUpload] is the separate autorun/adb path (BuildConfig collection endpoint), kept apart
* from the user-facing upload so a debugging convenience can never be mistaken for, or
* silently satisfy, the consent-gated one.
*/
fun run(devUpload: Boolean = false) {
if (state.running) return
collected.clear()
state = state.copy(running = true, currentStep = "starting", document = null,
uploadStatus = null, archiveStatus = null)
runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() }
step("archiving")
val archived = withContext(Dispatchers.IO) { store.archive(doc) }
var archiveStatus = if (archived != null) {
"archived locally (${store.list().size} runs kept)"
} else {
"not archived — archiving is off in settings"
}
var status: String? = null
if (devUpload) {
state = state.copy(currentStep = "uploading report")
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
}
if (archived != null && settings.autoUpload) {
state = state.copy(currentStep = "uploading to server")
val outcome = withContext(Dispatchers.IO) { store.upload(archived.id) }
archiveStatus += " · " + describe(outcome)
}
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = status, archiveStatus = archiveStatus,
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
private fun describe(o: RunStore.UploadOutcome): String = when (o) {
is RunStore.UploadOutcome.Sent -> "uploaded to ${o.serverName} (${o.detail})"
is RunStore.UploadOutcome.Refused -> "server refused the upload: ${o.reason}"
is RunStore.UploadOutcome.Failed -> "upload failed: ${o.detail}"
RunStore.UploadOutcome.NotConfigured -> "no server configured, so nothing was uploaded"
}
// ---- history ---------------------------------------------------------------------
fun refreshHistory() {
viewModelScope.launch {
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteRun(id: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) { store.delete(id) }
state = state.copy(history = withContext(Dispatchers.IO) { store.list() })
}
}
fun deleteAllRuns() {
viewModelScope.launch {
val n = withContext(Dispatchers.IO) { store.deleteAll() }
state = state.copy(
history = emptyList(),
archiveStatus = "deleted $n archived run(s)",
)
}
}
/** Uploads one already-archived run on demand, regardless of the auto-upload setting. */
fun uploadRun(id: String) {
viewModelScope.launch {
state = state.copy(archiveStatus = "uploading …")
val outcome = withContext(Dispatchers.IO) { store.upload(id) }
state = state.copy(
archiveStatus = describe(outcome),
history = withContext(Dispatchers.IO) { store.list() },
)
}
}
/** The archived document as stored, for export. */
suspend fun readRun(id: String): String? = withContext(Dispatchers.IO) { store.read(id) }
/** The exact bytes an upload would send, for the settings screen's preview. */
suspend fun uploadPreview(id: String): String? = withContext(Dispatchers.IO) {
store.read(id)?.let { store.redactedForUpload(it) }
}
fun archivedBytes(): Long = store.totalBytes()
/** Re-applies retention after the user changes the limits. */
fun applyRetention() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { store.purgeNow() }
state = state.copy(
history = withContext(Dispatchers.IO) { store.list() },
archiveStatus = if (result.isEmpty) "nothing to purge"
else "purged ${result.removed.size} run(s), freed ${result.freedBytes / 1024} kB",
)
}
}
/**
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
* a partial run is for the person looking at the screen, not for the record.
*/
fun cancel() {
if (!state.running) return
runJob?.cancel()
val doc = buildDocument(collected.toList())
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
archiveStatus = "partial run — not archived",
history = state.history,
)
}
private suspend fun measure(): MeasurementDocument {
val ctx = getApplication<Application>()
val ids = RunIds().also { runIds = it }
val startWall = Instant.now().toString().also { runStartWall = it }
step("reading networks")
val entries = NetworkInventory.snapshot(ctx)
val networks = entries.map { it.model }.also { runNetworks = it }
val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
IcmpProbe(entries, v6 = false),
IcmpProbe(entries, v6 = true),
CaptivePortalProbe(entries),
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
// the reference deployment until profiles/enrollment land in the UI.
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
StunProbe(serverHost = "fmr-1.echo-lot.app"),
)
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
val shizukuEstimateMs = 8_000L
val totalSteps = probes.size + 1
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
etaSeconds = ((remainingMs + 999) / 1000).toInt())
val tests = ArrayList<Test>()
for ((i, p) in probes.withIndex()) {
step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
val result = (
try {
p.run(ctx, ids)
} catch (t: Throwable) {
Test(
id = ids.uuid(), type = p.type, tier = p.tier,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED,
error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
)
}
)
tests.add(result); collected.add(result)
remainingMs -= p.estimatedMs
}
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
val shizukuTest = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) {
Test(
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
)
}
tests.add(shizukuTest); collected.add(shizukuTest)
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
return buildDocument(tests)
}
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
private fun buildDocument(tests: List<Test>): MeasurementDocument {
val findings = deriveFindings(tests, runNetworks)
return MeasurementDocument(
run = Run(
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
endedAt = Instant.now().toString(),
clock = Clock(monoOriginWall = runStartWall),
app = AppInfo(
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
),
device = DeviceInfo(
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
),
tiers = Tiers(app = true, shizuku = runShizukuOk),
),
networks = runNetworks,
tests = tests,
findings = findings,
summary = Verdicts.derive(tests, findings),
)
}
/**
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
*/
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
networks.any { n ->
n.link.addresses.any { a ->
a.addr.contains(':') &&
!a.addr.startsWith("fe80", ignoreCase = true) &&
!a.addr.startsWith("::1")
} || n.link.routes.any { it.dst == "::/0" }
}
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
val out = ArrayList<Finding>()
val ids = RunIds()
for (t in tests) {
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
val ev = t.evidence?.toString() ?: ""
when {
ev.contains("\"captive_portal\"") -> out.add(
Finding(
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "Captive portal intercepting connections",
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
t.status == TestStatus.FAILED -> out.add(
Finding(
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY,
severity = Severity.HIGH, confidence = Confidence.HIGH,
title = "No working internet on any network",
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.DNS_CANARY) {
val ev = t.evidence?.toString() ?: ""
if (ev.contains("MISMATCH")) {
out.add(
Finding(
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS,
severity = Severity.HIGH, confidence = Confidence.HIGH,
title = "DNS answers are being rewritten",
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
} else if (ev.contains("\"reached_authoritative\":false")) {
out.add(
Finding(
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS,
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM,
title = "Canary queries don't reach the authoritative server",
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.NAT_STUN_5780 && t.status == TestStatus.OK) {
val ev = t.evidence?.toString() ?: ""
if (ev.contains("address/port-dependent (symmetric NAT")) {
out.add(
Finding(
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "Symmetric NAT — peer-to-peer connections need a relay",
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
// and that is not a defect. What IS a defect is IPv6 that the network claims to
// provide (a global address or a default route from RA/DHCPv6) but that does not
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
// depends on whether v6 was provisioned at all.
if (ipv6Provisioned(networks)) {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "IPv6 is configured but not working",
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
} else {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
severity = Severity.INFO, confidence = Confidence.HIGH,
title = "IPv4-only network (no IPv6 offered)",
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
}
return out
}
private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
state = state.copy(
currentStep = s, stepsDone = done, stepsTotal = total,
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
)
}
}
@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.content.SharedPreferences
import app.echo_lot.archive.RetentionPolicy
import app.echo_lot.privacy.PrivacyLevel
import java.security.SecureRandom
/**
* User settings for archiving, uploading and anonymization.
*
* Defaults are the conservative reading of "an engineer's tool that still respects the person
* holding it": keep history (that is the point of the archive), never upload without being asked,
* and when uploading, strip identifiers unless the user says this is their own server.
*
* SharedPreferences rather than DataStore because these are a dozen scalars read synchronously at
* the start of a run; a coroutine-flow store would add a dependency and a lifecycle for nothing.
*/
class Settings(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences("echolot-settings", Context.MODE_PRIVATE)
// ---- archive ---------------------------------------------------------------------
var archiveEnabled: Boolean
get() = prefs.getBoolean(ARCHIVE_ENABLED, true)
set(v) = prefs.edit().putBoolean(ARCHIVE_ENABLED, v).apply()
/** 0 = no ceiling. */
var maxRuns: Int
get() = prefs.getInt(MAX_RUNS, 100)
set(v) = prefs.edit().putInt(MAX_RUNS, v.coerceAtLeast(0)).apply()
var maxAgeDays: Int
get() = prefs.getInt(MAX_AGE_DAYS, 90)
set(v) = prefs.edit().putInt(MAX_AGE_DAYS, v.coerceAtLeast(0)).apply()
var maxTotalMb: Int
get() = prefs.getInt(MAX_TOTAL_MB, 64)
set(v) = prefs.edit().putInt(MAX_TOTAL_MB, v.coerceAtLeast(0)).apply()
fun retention(): RetentionPolicy = RetentionPolicy(
enabled = archiveEnabled,
maxRuns = maxRuns,
maxAgeDays = maxAgeDays,
maxTotalBytes = maxTotalMb.toLong() * 1024 * 1024,
)
// ---- upload ----------------------------------------------------------------------
/**
* Off by default. Measurement data describes the network the user is standing in; sending it
* anywhere is a decision they make, not one they discover after the fact.
*/
var autoUpload: Boolean
get() = prefs.getBoolean(AUTO_UPLOAD, false)
set(v) = prefs.edit().putBoolean(AUTO_UPLOAD, v).apply()
/** Anonymization applied before a run leaves the device. Never applied to the local archive. */
var privacyLevel: PrivacyLevel
get() = PrivacyLevel.fromWire(prefs.getString(PRIVACY_LEVEL, PrivacyLevel.BALANCED.wire))
set(v) = prefs.edit().putString(PRIVACY_LEVEL, v.wire).apply()
/**
* Whether pseudonyms stay stable across runs. That makes history diffable ("same SSID as
* last week") and is what someone wants on their own server — but it also produces an
* identifier that links a device's uploads, so it is off unless chosen.
*/
var stableSalt: Boolean
get() = prefs.getBoolean(STABLE_SALT, false)
set(v) = prefs.edit().putBoolean(STABLE_SALT, v).apply()
/**
* The device-local secret behind stable pseudonyms. Generated once, never leaves the device,
* and clearing it (via [resetSalt]) breaks the link to everything uploaded before.
*/
fun saltSecret(): ByteArray {
prefs.getString(SALT_SECRET, null)?.let { return hex(it) }
val fresh = ByteArray(32).also { SecureRandom().nextBytes(it) }
prefs.edit().putString(SALT_SECRET, fresh.joinToString("") { "%02x".format(it) }).apply()
return fresh
}
fun resetSalt() = prefs.edit().remove(SALT_SECRET).apply()
// ---- server ----------------------------------------------------------------------
var serverUrl: String
get() = prefs.getString(SERVER_URL, "") ?: ""
set(v) = prefs.edit().putString(SERVER_URL, v.trim()).apply()
var serverPin: String
get() = prefs.getString(SERVER_PIN, "") ?: ""
set(v) = prefs.edit().putString(SERVER_PIN, v.trim()).apply()
var serverCredential: String
get() = prefs.getString(SERVER_CRED, "") ?: ""
set(v) = prefs.edit().putString(SERVER_CRED, v.trim()).apply()
val serverConfigured: Boolean
get() = serverUrl.isNotBlank() && serverPin.isNotBlank() && serverCredential.isNotBlank()
private fun hex(s: String) = ByteArray(s.length / 2) {
((Character.digit(s[it * 2], 16) shl 4) or Character.digit(s[it * 2 + 1], 16)).toByte()
}
private companion object {
const val ARCHIVE_ENABLED = "archive_enabled"
const val MAX_RUNS = "archive_max_runs"
const val MAX_AGE_DAYS = "archive_max_age_days"
const val MAX_TOTAL_MB = "archive_max_total_mb"
const val AUTO_UPLOAD = "auto_upload"
const val PRIVACY_LEVEL = "privacy_level"
const val STABLE_SALT = "stable_salt"
const val SALT_SECRET = "salt_secret"
const val SERVER_URL = "server_url"
const val SERVER_PIN = "server_pin"
const val SERVER_CRED = "server_credential"
}
}
@@ -0,0 +1,214 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import app.echo_lot.privacy.PrivacyLevel
/**
* Archiving, upload and anonymization settings.
*
* The screen is written to make the consequences legible rather than to look tidy: every toggle
* says what it means for the user's data in a sentence, and the privacy levels are described by
* what survives them, because "balanced" on its own tells nobody anything.
*/
@Composable
fun SettingsScreen(
settings: Settings,
archivedRuns: Int,
archivedBytes: Long,
onApplyRetention: () -> Unit,
onDeleteAll: () -> Unit,
onPreviewUpload: () -> Unit,
onBack: () -> Unit,
) {
// SharedPreferences is not observable, so mirror each value into Compose state and write
// through on change. A dozen scalars; a store with flows would be ceremony for nothing.
var archiveEnabled by remember { mutableStateOf(settings.archiveEnabled) }
var maxRuns by remember { mutableStateOf(settings.maxRuns.toString()) }
var maxAgeDays by remember { mutableStateOf(settings.maxAgeDays.toString()) }
var maxTotalMb by remember { mutableStateOf(settings.maxTotalMb.toString()) }
var autoUpload by remember { mutableStateOf(settings.autoUpload) }
var privacy by remember { mutableStateOf(settings.privacyLevel) }
var stableSalt by remember { mutableStateOf(settings.stableSalt) }
var serverUrl by remember { mutableStateOf(settings.serverUrl) }
var serverPin by remember { mutableStateOf(settings.serverPin) }
var serverCred by remember { mutableStateOf(settings.serverCredential) }
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = onBack) { Text(" Back") }
Text("Settings", style = MaterialTheme.typography.titleLarge)
}
// ---- archive ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Archive", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Keep finished runs on this device",
detail = "History is what makes a run comparable later. Archived runs are " +
"stored complete and unredacted — anonymization only applies to uploads.",
checked = archiveEnabled,
) { archiveEnabled = it; settings.archiveEnabled = it }
Text(
"Purge automatically when a run exceeds any of these. 0 turns that limit off.",
style = MaterialTheme.typography.bodySmall,
)
NumberField("Keep at most (runs)", maxRuns) {
maxRuns = it; settings.maxRuns = it.toIntOrNull() ?: 0
}
NumberField("Delete older than (days)", maxAgeDays) {
maxAgeDays = it; settings.maxAgeDays = it.toIntOrNull() ?: 0
}
NumberField("Keep at most (MB)", maxTotalMb) {
maxTotalMb = it; settings.maxTotalMb = it.toIntOrNull() ?: 0
}
Text(
"$archivedRuns run(s), ${archivedBytes / 1024} kB stored",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = onApplyRetention) { Text("Apply now") }
TextButton(onClick = onDeleteAll) { Text("Delete all runs") }
}
}
}
// ---- privacy ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("What leaves the device", style = MaterialTheme.typography.titleMedium)
Text(
"Applied to uploads only. Measurements, verdicts and finding codes survive " +
"every level — only the parts that identify you or your network change.",
style = MaterialTheme.typography.bodySmall,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
for (level in PrivacyLevel.entries) {
FilterChip(
selected = privacy == level,
onClick = { privacy = level; settings.privacyLevel = level },
label = { Text(level.wire) },
)
}
}
Text(privacyExplanation(privacy), style = MaterialTheme.typography.bodySmall)
Toggle(
label = "Stable pseudonyms across runs",
detail = "Lets you compare uploaded runs over time (same SSID reads the same " +
"each time). It also links your uploads together, so leave it off on a " +
"server you don't run yourself.",
checked = stableSalt,
) { stableSalt = it; settings.stableSalt = it }
TextButton(onClick = onPreviewUpload) { Text("Preview what an upload would send") }
}
}
// ---- upload ----
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Upload", style = MaterialTheme.typography.titleMedium)
Toggle(
label = "Upload finished runs automatically",
detail = "Sends each completed run to the server below, anonymized to the " +
"level above. The server may require more anonymization than you chose; " +
"it can never require less.",
checked = autoUpload,
) { autoUpload = it; settings.autoUpload = it }
OutlinedTextField(
value = serverUrl, onValueChange = { serverUrl = it; settings.serverUrl = it },
label = { Text("Server URL") }, singleLine = true, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverPin, onValueChange = { serverPin = it; settings.serverPin = it },
label = { Text("Certificate pin (SPKI, base64)") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = serverCred,
onValueChange = { serverCred = it; settings.serverCredential = it },
label = { Text("Device credential") }, singleLine = true,
textStyle = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.fillMaxWidth(),
)
Text(
if (settings.serverConfigured) "Server configured."
else "Uploads stay off until all three fields are set.",
style = MaterialTheme.typography.bodySmall,
)
}
}
Spacer(Modifier.height(24.dp))
}
}
private fun privacyExplanation(level: PrivacyLevel): String = when (level) {
PrivacyLevel.FULL ->
"Nothing is removed: SSIDs, MAC addresses, hostnames and discovered neighbours are sent " +
"as measured. Appropriate for a server you run yourself."
PrivacyLevel.BALANCED ->
"Network names and hostnames become pseudonyms, MAC addresses keep only their vendor " +
"prefix, public IP addresses keep only their /16, and discovered neighbours (SSDP, " +
"ARP, nearby networks) are dropped entirely. Private addresses stay readable, since " +
"192.168.1.1 describes the topology and not the person."
PrivacyLevel.STRICT ->
"Only numbers: test results, metrics and finding codes. No network description, no raw " +
"evidence, no finding text. Nothing left can identify a network."
}
@Composable
private fun Toggle(label: String, detail: String, checked: Boolean, onChange: (Boolean) -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(detail, style = MaterialTheme.typography.bodySmall)
}
Switch(checked = checked, onCheckedChange = onChange)
}
}
@Composable
private fun NumberField(label: String, value: String, onChange: (String) -> Unit) {
OutlinedTextField(
value = value,
onValueChange = { s -> onChange(s.filter { it.isDigit() }.take(7)) },
label = { Text(label) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon background: the brand "tile" gradient (assets/branding). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient android:startX="54" android:startY="0" android:endX="54" android:endY="108"
android:type="linear">
<item android:offset="0" android:color="#FF0E2433"/>
<item android:offset="1" android:color="#FF071522"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon foreground: the Focus mark (assets/branding/icon-adaptive-foreground.svg),
SVG transform baked in so the art sits inside the 66dp safe circle. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:strokeColor="#FFB454" android:strokeAlpha="0.3" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M47.6,54.0 a6.4,6.4 0 1,0 12.8,0 a6.4,6.4 0 1,0 -12.8,0"/>
<path android:fillColor="#FFFFB454" android:pathData="M50.4,54.0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M42.0,48.4 V42.0 H48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M59.6,42.0 H66.0 V48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M66.0,59.6 V66.0 H59.6"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M48.4,66.0 H42.0 V59.6"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

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>
+11
View File
@@ -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
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The on-device run archive: measurement documents on disk, with retention.
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
// — the part with edge cases — are unit-testable without a device.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package archive keeps completed measurement runs on the device.
//
// The point of an archive is the second run: "this network was fine on Tuesday" is only
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
// job somebody remembers to write — every save enforces it.
//
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
// write never leaves a half-document that reads as real.
package app.echo_lot.archive
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/** Index entry for one archived run — enough for a history list without opening the documents. */
@Serializable
data class ArchivedRun(
val id: String,
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
@SerialName("started_at") val startedAt: String? = null,
val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0,
val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null,
)
/**
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
* them. Zero disables that limit.
*
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
* a pattern ("it degrades every evening") without turning the phone into an archive of every
* network its owner ever walked past.
*/
@Serializable
data class RetentionPolicy(
/**
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
* a user who turns all the caps off ends up with an empty history.
*/
val enabled: Boolean = true,
@SerialName("max_runs") val maxRuns: Int = 100,
@SerialName("max_age_days") val maxAgeDays: Int = 90,
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
) {
companion object {
/** Archiving off: runs are shown once and never written. */
val KeepNothing = RetentionPolicy(enabled = false)
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
val Default = RetentionPolicy()
}
val keepsAnything: Boolean get() = enabled
}
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
val isEmpty: Boolean get() = removed.isEmpty()
}
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
init {
dir.mkdirs()
}
/**
* Writes one run and applies retention. Returns the index entry, or null when the policy
* keeps nothing at all — in which case nothing is written, rather than written and instantly
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
*/
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
if (!policy.keepsAnything) return null
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
writeAtomically(File(dir, meta.id + EXT), runJson)
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
purge(policy)
return meta
}
/** History, newest first. */
fun list(): List<ArchivedRun> =
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
.mapNotNull { f ->
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
}
.sortedByDescending { it.savedAtEpochMs }
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
fun delete(id: String): Boolean {
val s = safe(id)
val doc = File(dir, s + EXT).delete()
File(dir, s + META_EXT).delete()
return doc
}
fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) {
val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return
writeAtomically(
f,
json.encodeToString(
ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName),
),
)
}
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
/**
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
* should not be pre-empted by a size sweep deleting last week instead.
*/
fun purge(policy: RetentionPolicy): PurgeResult {
val removed = ArrayList<String>()
var freed = 0L
fun drop(r: ArchivedRun) {
if (delete(r.id)) {
removed.add(r.id)
freed += r.sizeBytes
}
}
var kept = list()
if (policy.maxAgeDays > 0) {
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
stale.forEach(::drop)
kept = fresh
}
if (policy.maxTotalBytes > 0) {
var total = kept.sumOf { it.sizeBytes }
// Oldest first until we are under the ceiling.
for (r in kept.reversed()) {
if (total <= policy.maxTotalBytes) break
drop(r)
total -= r.sizeBytes
}
kept = kept.filter { it.id !in removed }
}
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
}
return PurgeResult(removed, freed)
}
// ---- internals ---------------------------------------------------------------------
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
val run = doc["run"]?.jsonObject ?: return null
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
return ArchivedRun(
id = id,
savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
)
}
private fun writeAtomically(target: File, content: String) {
val tmp = File(target.parentFile, target.name + ".tmp")
tmp.writeText(content)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
private fun safe(id: String): String = buildString {
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
}.take(64)
private companion object {
const val EXT = ".json"
const val META_EXT = ".meta.json"
}
}
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.archive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RunArchiveTest {
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
private fun archive() = RunArchive(dir) { clock }
@AfterTest fun cleanup() { dir.deleteRecursively() }
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
}
@Test
fun savedRunsComeBackNewestFirst() {
val a = archive()
for (i in 1..3) {
a.save(doc("run-$i"))
clock += 60_000
}
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
}
@Test
fun theIndexSummarisesTheDocument() {
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
assertEquals("warn", meta.verdict)
assertEquals(4, meta.findingCount)
assertEquals("balanced", meta.anonymization)
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
assertFalse(meta.uploaded)
}
@Test
fun theDocumentComesBackByteForByte() {
val a = archive()
val original = doc("run-1")
a.save(original)
assertEquals(original, a.read("run-1"))
}
@Test
fun countLimitKeepsTheNewest() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
for (i in 1..7) {
a.save(doc("run-$i"), policy)
clock += 60_000
}
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
}
@Test
fun ageLimitDropsRunsPastTheWindow() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
a.save(doc("old"), policy)
clock += 30L * 24 * 60 * 60 * 1000 // a month later
a.save(doc("new"), policy)
assertEquals(listOf("new"), a.list().map { it.id })
}
@Test
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
val a = archive()
val one = doc("x", pad = 900).toByteArray().size.toLong()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
for (i in 1..5) {
a.save(doc("run-$i", pad = 900), policy)
clock += 60_000
}
val kept = a.list()
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
assertEquals("run-5", kept.first().id, "the newest run must always survive")
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
}
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
@Test
fun keepNothingWritesNothing() {
val a = archive()
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
assertTrue(a.list().isEmpty())
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
}
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
// one: turning every limit off should keep everything, not wipe the history.
@Test
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
assertEquals(5, a.list().size)
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
}
@Test
fun uploadStateIsRecorded() {
val a = archive()
a.save(doc("run-1"))
a.markUploaded("run-1", "fmr")
val meta = a.list().single()
assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
}
@Test
fun deleteRemovesBothFiles() {
val a = archive()
a.save(doc("run-1"))
assertTrue(a.delete("run-1"))
assertTrue(a.list().isEmpty())
assertNull(a.read("run-1"))
assertEquals(0, dir.listFiles()?.size ?: 0)
}
@Test
fun malformedInputIsRejectedRatherThanArchived() {
val a = archive()
assertNull(a.save("not json"))
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
assertTrue(a.list().isEmpty())
}
// Run ids come from a document that may have been produced elsewhere; they must not be able
// to write outside the archive directory.
@Test
fun runIdsCannotEscapeTheArchiveDirectory() {
val a = archive()
a.save(doc("../../evil"))
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
}
@Test
fun purgeReportsWhatItRemoved() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
assertEquals(3, result.removed.size)
assertTrue(result.freedBytes > 0)
assertEquals(2, a.list().size)
}
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The measurement run engine: composes core-protocol probes into
// core-measurement documents. Pure Kotlin/JVM, so it is unit-testable and can
// run a full server-facing measurement against a live server.
dependencies {
implementation(project(":core-protocol"))
implementation(project(":core-measurement"))
implementation(project(":core-privacy"))
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test {
useJUnitPlatform()
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET")
.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,89 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
*
* This is the direction a client cannot measure alone — only the far end can push large or
* numerous packets toward it — so it is also the direction that needs the anti-amplification
* grant, and this test is the proof that the grant path works end to end.
*/
class LiveGrantedTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun downstreamTrainAndBigSend() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("capabilities: ${profile.capabilities}")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
val echo = ps.echo()
println("primed with echo rtt=${echo?.rttMs}")
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
val dtResp = control.action(
cred, session.sessionId,
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
)
println("downtrain accepted: ${dtResp.take(160)}")
val down = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val seqs = down.map { it.seq }.toSet()
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
"sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send with DF: the largest size that arrives is the downstream path MTU ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val dfResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
)
println("big_send(df) accepted: ${dfResp.take(200)}")
val dfArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(df) arrived: $dfArrived")
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
val pathMtu = dfArrived.max()
// --- and without DF, to see whether fragments get through above that ---
val fragResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
)
println("big_send(frag) accepted: ${fragResp.take(200)}")
val fragArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(frag) arrived: $fragArrived")
// The distinction the DF flag exists for: fragmented delivery may exceed the
// unfragmented path MTU, and reporting the former as the latter would be a lie.
println("downstream path MTU (payload bytes) = $pathMtu; " +
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
"fragmented delivery should reach at least as far as unfragmented")
}
control.deleteSession(cred, session.sessionId)
}
}
@@ -0,0 +1,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,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
*
* The point is not that the HTTP works — it is that what comes *back off the server* has been
* stripped. Uploading and then re-reading the stored document is the only check that proves the
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveUploadTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val json = Json { prettyPrint = false }
private fun sampleRun(id: String) = """
{
"schema": "echolot/measurement",
"run": {
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
"notes": "kitchen table",
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
}],
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
"summary": {"verdict": "warn"}
}
""".trimIndent()
@Test
fun uploadRoundTrip() {
if (url == null || pin == null || cred == null) {
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
val policy = profile.uploads
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
val redacted = json.encodeToString(
JsonObject.serializer(),
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
)
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
if (!policy.accepted) {
// A server configured to refuse must refuse — that is the behaviour worth asserting.
try {
control.uploadRun(cred, redacted)
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
} catch (e: UploadRefused) {
println("upload correctly refused: ${e.message?.take(140)}")
return
}
}
val created = control.uploadRun(cred, redacted)
println("stored: ${created.take(200)}")
val listed = control.listRuns(cred)
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
val fetched = control.getRun(cred, runId)
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
println("round trip verified: identifiers stripped, measurements intact")
control.deleteRun(cred, runId)
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
println("deleted")
}
}
@@ -0,0 +1,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))
}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
// dependency-free beyond JSON: it must be trivially auditable, because a bug
// here leaks a user's network onto someone else's server.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8.
//
// The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone.
//
// Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */
FULL("full"),
/**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast.
*/
BALANCED("balanced"),
/**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work.
*/
STRICT("strict");
companion object {
fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
}
}
/**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own the app makes that an explicit choice, not a default.
*/
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
}
}
/**
* Transforms a measurement document to [level].
*
* Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, ) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise see [Classification].
*/
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out)
}
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject {
put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run")
}
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
}
// ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) {
val childPath = path + k
when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath))
}
}
}
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
else v
}
private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value)
LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
}
// ---- per-type transforms -------------------------------------------------------------
/**
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half "the RA comes from a MikroTik" survives, "…from THAT
* MikroTik" does not.
*/
private fun macPreservingOui(value: String): String {
val sep = if (value.contains('-')) '-' else ':'
val parts = value.split(sep)
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
val nic = pseudo("mac", value) { it }
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
.joinToString(sep.toString())
.lowercase(Locale.ROOT)
}
/**
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
* CGNAT addresses say something about the topology and nothing about the person, and a run
* where 192.168.1.1 became a random public address would be actively misleading to read.
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/
private fun ip4(value: String): String {
val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() }
val reserved = n[0] == 10 ||
(n[0] == 172 && n[1] in 16..31) ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) ||
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value
val h = pseudo("ip4", value) { it }
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
}
/**
* IPv6 keeps the scope and the first 32 bits (so 2001:db8: still reads as global unicast in
* the same allocation) and pseudonymizes the rest the interface identifier is the part that
* is a device fingerprint, especially with EUI-64.
*/
private fun ip6(value: String): String {
val v = value.lowercase(Locale.ROOT)
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v
val h = pseudo("ip6", value) { it }
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
}
/**
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
* or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
*/
private fun fqdn(value: String): String {
if (value.isEmpty()) return value
val trailing = value.endsWith(".")
val labels = value.trimEnd('.').split(".")
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
}
// ---- STRICT ---------------------------------------------------------------------------
/**
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
* (it interpolates real names), networks go entirely.
*/
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
/** The logical types of measurement-schema.md §8. */
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
/**
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
*
* This is a table on purpose. The alternative annotating the Kotlin models and reflecting over
* them spreads the answer across every module and makes "what exactly gets uploaded?" a
* question you answer by reading the whole app. Here it is one file a reviewer can check against
* the spec in a sitting, and a new field that nobody classified stays visible in the output
* rather than being silently mangled.
*
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
* an SSID wrongly kept is a leak nobody notices.
*/
object Classification {
private val byKey: Map<String, LogicalType> = buildMap {
listOf(
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
).forEach { put(it, LogicalType.IP4) }
listOf(
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
"link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
}
/**
* Whole subtrees that BALANCED removes rather than pseudonymizes.
*
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
* case: they describe other people's devices, they are a household fingerprint even with the
* names hashed, and no metric depends on them. Dropping beats mangling.
*/
private val droppedPaths: List<List<String>> = listOf(
listOf("networks", "neighbors"),
listOf("networks", "arp"),
listOf("networks", "wifi", "scan_results"),
listOf("run", "device", "security_patch"),
)
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
byKey[key]?.let { return it }
// Inside a discovery/neighbour container every string is someone's device name until
// proven otherwise, so classify unknown strings there as free text rather than passing
// them through.
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
return null
}
fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These tests are the audit of the anonymizer: each one states a property someone's privacy
* depends on, so a regression here fails loudly rather than quietly leaking.
*/
class AnonymizerTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
private fun sample(): JsonObject = json.parseToJsonElement(
"""
{
"schema": "echolot/measurement",
"run": {
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
}],
"tests": [{
"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
}],
"findings": [{
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
}],
"summary": {"verdict": "warn"}
}
""".trimIndent(),
).jsonObject
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
private fun flat(e: JsonElement): String = e.toString()
@Test
fun fullLeavesTheDocumentAloneButRecordsThat() {
val out = anon(PrivacyLevel.FULL)
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
}
@Test
fun balancedRemovesTheSsidAndTheNotes() {
val text = flat(anon(PrivacyLevel.BALANCED))
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
}
@Test
fun balancedDropsNeighbourInventoriesEntirely() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
}
@Test
fun balancedKeepsTheVendorHalfOfAMac() {
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
}
@Test
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
"RFC1918 says nothing about the user and everything about the topology")
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
"the /16 should survive for ISP-level context")
}
@Test
fun linkLocalIsKeptButGlobalV6IsNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
}
@Test
fun metricsAndVerdictsAreNeverTouched() {
for (level in PrivacyLevel.entries) {
val out = anon(level)
val t = out["tests"]!!.jsonArray[0].jsonObject
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
"$level changed a metric")
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
}
}
@Test
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
for (level in PrivacyLevel.entries) {
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
}
}
@Test
fun strictDropsEvidenceAndProse() {
val out = anon(PrivacyLevel.STRICT)
assertNull(out["networks"], "STRICT should not describe the network at all")
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
}
@Test
fun pseudonymsAreConsistentWithinADocument() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
).jsonObject
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
assertNotEquals(a, c, "different SSIDs must not collide")
}
@Test
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
assertNotEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
"a per-run salt must not produce a cross-run tracking identifier",
)
}
@Test
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val secret = ByteArray(32) { 7 }
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
assertEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
)
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
}
@Test
fun theDeclaredLevelMatchesWhatWasApplied() {
for (level in PrivacyLevel.entries) {
assertEquals(
level.wire,
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
)
}
}
@Test
fun serverMinimumWins() {
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
}
}
+31
View File
@@ -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,146 @@
// 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)
*/
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
class UploadRefused(message: String) : Exception(message)
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
}
/**
* Uploads one measurement document. The body is sent exactly as given whatever the
* anonymizer produced is what the server stores, so what the user was shown is what left
* the device. Returns the server's index entry as raw JSON.
*
* A refusal is not an error condition to retry: 403 means the operator's policy says no
* (uploads off, accounts required, or not anonymized enough), so it is surfaced as
* [UploadRefused] for the caller to show rather than swallow.
*/
fun uploadRun(credential: String, documentJson: String): String {
val conn = open("/v1/runs", "POST", credential)
writeJson(conn, documentJson)
val body = body(conn)
when (conn.responseCode) {
in 200..299 -> return body
403 -> throw UploadRefused(body)
413 -> throw UploadRefused("run is larger than this server accepts: $body")
else -> error("upload failed: ${conn.responseCode} $body")
}
}
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode}" }
return body(conn)
}
fun getRun(credential: String, runId: String): String {
val conn = open("/v1/runs/$runId", "GET", credential)
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode}" }
return body(conn)
}
fun deleteRun(credential: String, runId: String) {
open("/v1/runs/$runId", "DELETE", credential).responseCode
}
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
}
}

Some files were not shown because too many files have changed in this diff Show More