Compare commits

...
Author SHA1 Message Date
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
143 changed files with 10942 additions and 29 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
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
// AGP 9 built-in Kotlin — no kotlin.android here (see core-probe note).
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "app.echo_lot.app"
compileSdk = 36
defaultConfig {
applicationId = "app.echo_lot.app"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
// Automation: `adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true`
// runs a measurement immediately and POSTs the report here (dev collection endpoint).
buildConfigField("String", "REPORT_UPLOAD_URL", "\"http://89.185.109.150:443/report\"")
buildConfigField("String", "REPORT_UPLOAD_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
}
buildTypes {
release { isMinifyEnabled = false }
debug {
// The dev build is a separate app: own package (installs alongside a real
// Echolot), own label and a DEV-badged icon (src/debug/res).
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(project(":core-measurement"))
implementation(project(":core-protocol"))
implementation(project(":core-engine"))
implementation(project(":core-probe"))
implementation(project(":core-shizuku"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
debugImplementation(libs.androidx.ui.tooling)
}
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,340 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import app.echo_lot.measurement.*
class MainActivity : ComponentActivity() {
private val permissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { /* proceed regardless */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestRuntimePermissions()
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(color = MaterialTheme.colorScheme.background) {
val vm: RunViewModel = viewModel()
// Automation entry point:
// adb shell am start -n app.echo_lot.app/.MainActivity --ez autorun true
// starts a run immediately and uploads the report, so an unattended
// measurement needs no UI tapping and no adb round-trip to collect.
val autorun = intent?.getBooleanExtra("autorun", false) == true
androidx.compose.runtime.LaunchedEffect(autorun) {
if (autorun) vm.run(upload = true)
}
// In autorun the app is a batch job: once the run is done AND the upload
// succeeded, show the result briefly, then close so the device is left as it
// was found. On failure it stays open so the error is visible.
val st = vm.state
androidx.compose.runtime.LaunchedEffect(autorun, st.running, st.uploadStatus) {
if (autorun && !st.running && st.document != null &&
st.uploadStatus?.startsWith("uploaded") == true
) {
kotlinx.coroutines.delay(3000)
finish()
}
}
EcholotScreen(
state = vm.state,
onRun = { vm.run() },
onCancel = vm::cancel,
onDeveloperOptions = {
runCatching {
startActivity(
app.echo_lot.shizuku.ShizukuAvailability.developerOptionsIntent()
)
}
},
onShizukuAction = {
// Shizuku can only be started from its own app (the pairing flow lives
// there), so send the user straight to it; if it is already running we
// just need permission.
when (vm.state.shizukuState) {
app.echo_lot.shizuku.ShizukuAvailability.State.NEEDS_PERMISSION ->
app.echo_lot.shizuku.ShizukuAvailability.requestPermission()
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING ->
app.echo_lot.shizuku.ShizukuAvailability.launchIntent(this)
?.let { startActivity(it) }
else -> Unit
}
},
onExport = { doc -> startActivity(Intent.createChooser(Report.share(this, doc), "Export Echolot run")) },
)
}
}
}
}
private fun requestRuntimePermissions() {
val perms = mutableListOf(Manifest.permission.ACCESS_FINE_LOCATION)
val missing = perms.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (missing.isNotEmpty()) permissionLauncher.launch(missing.toTypedArray())
}
}
private fun verdictColor(v: Verdict): Color = when (v) {
Verdict.GREEN -> Color(0xFF2E7D32)
Verdict.YELLOW -> Color(0xFFF9A825)
Verdict.RED -> Color(0xFFC62828)
Verdict.INCONCLUSIVE -> Color(0xFF616161)
}
private fun statusColor(s: TestStatus): Color = when (s) {
TestStatus.OK -> Color(0xFF66BB6A)
TestStatus.PARTIAL -> Color(0xFFFFB300)
TestStatus.FAILED -> Color(0xFFEF5350)
TestStatus.UNSUPPORTED, TestStatus.SKIPPED -> Color(0xFF9E9E9E)
}
@Composable
private fun EcholotScreen(
state: UiState,
onRun: () -> Unit,
onCancel: () -> Unit,
onShizukuAction: () -> Unit,
onDeveloperOptions: () -> Unit,
onExport: (MeasurementDocument) -> Unit,
) {
Column(
Modifier
.fillMaxSize()
// Android 15 draws edge-to-edge by default: without this the title runs under the
// status-bar clock and the camera cutout. safeDrawing covers status/navigation bars
// AND the display cutout, so text never lands where it can't be read.
.safeDrawingPadding()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Echolot", fontSize = 26.sp, fontWeight = FontWeight.SemiBold)
Text("measure, don't guess", color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 13.sp)
// Shell-tier readiness, before the run. Nothing is shown when Shizuku isn't installed —
// only users who actually use it get reminded that it must be running.
state.shizukuNotice?.let { notice ->
Card(
Modifier.fillMaxWidth().let { m ->
if (state.shizukuHint != null) m.clickable { onShizukuAction() } else m
},
colors = CardDefaults.cardColors(
containerColor = if (state.shizukuReady) Color(0xFF14301F) else Color(0xFF3A2E12),
),
) {
Column(Modifier.padding(10.dp)) {
Text(
notice, fontSize = 12.sp,
color = if (state.shizukuReady) Color(0xFF9CCFA8) else Color(0xFFFFD08A),
)
state.shizukuHint?.let {
Text(it, fontSize = 11.sp, fontWeight = FontWeight.SemiBold,
color = Color(0xFFFFB454))
}
// Wireless debugging must be ON before Shizuku's wireless start works, and
// that screen (unlike Shizuku's pairing activity) is publicly launchable.
if (state.shizukuState ==
app.echo_lot.shizuku.ShizukuAvailability.State.INSTALLED_NOT_RUNNING
) {
Text(
"Wireless debugging settings →",
Modifier.padding(top = 6.dp).clickable { onDeveloperOptions() },
fontSize = 11.sp, color = Color(0xFF35E0C4),
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) {
Button(onClick = onRun, enabled = !state.running) {
Text(if (state.running) "Running…" else "Run measurement")
}
if (state.running) {
OutlinedButton(onClick = onCancel) { Text("Cancel") }
}
state.document?.let { doc ->
OutlinedButton(onClick = { onExport(doc) }) { Text("Export JSON") }
}
}
state.uploadStatus?.let {
Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (state.running) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val frac = if (state.stepsTotal > 0)
state.stepsDone.toFloat() / state.stepsTotal else 0f
LinearProgressIndicator(
progress = { frac },
modifier = Modifier.fillMaxWidth(),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
if (state.stepsTotal > 0)
"test ${state.stepsDone + 1} of ${state.stepsTotal}" else "starting",
fontSize = 12.sp, fontWeight = FontWeight.Medium,
)
Text(state.currentStep ?: "", fontSize = 12.sp,
fontFamily = FontFamily.Monospace, modifier = Modifier.weight(1f))
if (state.etaSeconds > 0) {
Text("~${state.etaSeconds}s left", fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
state.document?.let { doc -> Results(doc) }
}
}
@Composable
private fun Results(doc: MeasurementDocument) {
val summary = doc.summary
if (summary != null) {
Card(colors = CardDefaults.cardColors(containerColor = verdictColor(summary.overall))) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Overall: ${summary.overall}", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
}
}
FlowCategories(summary.categories)
}
RouterPanel(doc)
SectionTitle("Networks (${doc.networks.size})")
for (n in doc.networks) {
Text("${n.transport.name.lowercase()} ${n.iface ?: ""}" +
n.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" },
fontSize = 13.sp, fontFamily = FontFamily.Monospace)
}
SectionTitle("Tests (${doc.tests.size})")
for (t in doc.tests) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Dot(statusColor(t.status))
Text(t.type, fontWeight = FontWeight.Medium, modifier = Modifier.weight(1f))
Text(t.status.name, color = statusColor(t.status), fontSize = 12.sp)
}
val ms = (t.endedMonoNs - t.startedMonoNs) / 1_000_000
Text("${t.tier.name.lowercase()} · ${ms} ms", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
t.metrics?.let { Text(it.toString(), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant) }
}
}
}
if (doc.findings.isNotEmpty()) {
SectionTitle("Findings (${doc.findings.size})")
for (f in doc.findings) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(f.title, fontWeight = FontWeight.Medium)
Text("${f.category.name.lowercase()} · ${f.severity.name.lowercase()}", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(f.description, fontSize = 12.sp)
}
}
}
}
}
/**
* Who advertises IPv6 here, and what box is it? Pulled from the link.ra_source evidence and shown
* up front — a rogue/misconfigured RA sender is a top cause of broken IPv6, and "some router" is
* not actionable without an identity.
*/
@Composable
private fun RouterPanel(doc: MeasurementDocument) {
val test = doc.tests.firstOrNull { it.type == TestType.LINK_RA_SOURCE } ?: return
val nets = (test.evidence?.get("networks") as? kotlinx.serialization.json.JsonArray) ?: return
if (nets.isEmpty()) return
SectionTitle("Router / IPv6 advertiser")
for (el in nets) {
val o = el as? kotlinx.serialization.json.JsonObject ?: continue
fun f(k: String): String? =
(o[k] as? kotlinx.serialization.json.JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val ra = f("ra_source")
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(f("network") ?: "network", fontWeight = FontWeight.Medium)
// The identity line: vendor/model if we could pin it down.
val identity = listOfNotNull(
f("upnp_manufacturer"), f("upnp_model"), f("ra_source_vendor"),
).distinct().joinToString(" · ").ifBlank { null }
identity?.let {
Text(it, fontWeight = FontWeight.SemiBold, color = Color(0xFF35E0C4), fontSize = 15.sp)
}
f("upnp_friendly_name")?.let { Row0("name", it) }
ra?.let { Row0("RA source", it) }
f("ra_source_mac")?.let { Row0("RA source MAC", it) }
f("ra_source_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("reverse DNS", it) }
f("v4_gateway")?.let { Row0("IPv4 gateway", it) }
f("v4_gateway_reverse_dns")?.takeIf { it != "(none)" }?.let { Row0("gateway rDNS", it) }
f("upnp_server")?.let { Row0("UPnP server", it) }
}
}
}
}
@Composable
private fun Row0(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("$label:", fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontSize = 12.sp, fontFamily = FontFamily.Monospace)
}
}
@Composable
private fun FlowCategories(categories: Map<String, CategorySummary>) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
for ((name, cat) in categories) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Dot(verdictColor(cat.verdict))
Text(name, modifier = Modifier.weight(1f))
Text("${cat.testsRun} run" + if (cat.testsFailed > 0) " · ${cat.testsFailed} failed" else "",
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun Dot(color: Color) {
Surface(color = color, shape = RoundedCornerShape(50), modifier = Modifier.size(12.dp)) {}
}
@Composable
private fun SectionTitle(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, modifier = Modifier.padding(top = 8.dp))
}
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import app.echo_lot.measurement.MeasurementDocument
import kotlinx.serialization.json.Json
import java.io.File
/** Serializes a measurement run to JSON and builds a share intent (measurement-schema.md §2). */
object Report {
private val json = Json { prettyPrint = true; encodeDefaults = true }
fun toJson(doc: MeasurementDocument): String =
json.encodeToString(MeasurementDocument.serializer(), doc)
fun share(ctx: Context, doc: MeasurementDocument): Intent {
val dir = File(ctx.cacheDir, "reports").apply { mkdirs() }
val file = File(dir, "echolot-run-${doc.run.id}.json")
file.writeText(toJson(doc))
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
return Intent(Intent.ACTION_SEND).apply {
type = "application/json"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
}
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import app.echo_lot.measurement.MeasurementDocument
import java.net.HttpURLConnection
import java.net.URL
/**
* Dev/automation helper: uploads a finished run to the collection endpoint so an unattended run
* (see MainActivity's `autorun` extra) needs no adb round-trip to retrieve its result. Off unless
* an upload URL is configured. Never throws — a failed upload must not lose the local report.
*/
object ReportUploader {
data class Result(val ok: Boolean, val detail: String)
fun upload(doc: MeasurementDocument): Result {
val url = BuildConfig.REPORT_UPLOAD_URL
if (url.isBlank()) return Result(false, "no upload URL configured")
return try {
val body = Report.toJson(doc).toByteArray()
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10_000
readTimeout = 15_000
doOutput = true
setRequestProperty("Content-Type", "application/json")
if (BuildConfig.REPORT_UPLOAD_SECRET.isNotBlank()) {
setRequestProperty("X-Beacon-Secret", BuildConfig.REPORT_UPLOAD_SECRET)
}
}
conn.outputStream.use { it.write(body) }
val code = conn.responseCode
val resp = (if (code in 200..299) conn.inputStream else conn.errorStream)
?.bufferedReader()?.use { it.readText() } ?: ""
conn.disconnect()
Result(code in 200..299, "HTTP $code ${resp.take(120)}")
} catch (t: Throwable) {
Result(false, t.message ?: t.javaClass.simpleName)
}
}
}
@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.app.Application
import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import app.echo_lot.measurement.*
import app.echo_lot.probe.CaptivePortalProbe
import app.echo_lot.probe.DnsCanaryProbe
import app.echo_lot.probe.IcmpProbe
import app.echo_lot.probe.LinkSnapshotProbe
import app.echo_lot.probe.StunProbe
import app.echo_lot.probe.NetworkInventory
import app.echo_lot.probe.Probe
import app.echo_lot.probe.ProbeIds
import app.echo_lot.probe.RouterIdentityProbe
import app.echo_lot.shizuku.ShizukuAvailability
import app.echo_lot.shizuku.ShizukuProbe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant
import java.util.UUID
data class UiState(
val running: Boolean = false,
val currentStep: String? = null,
val document: MeasurementDocument? = null,
/** Result line of an automated upload (autorun mode); null when not attempted. */
val uploadStatus: String? = null,
/** Progress: tests finished / total, and a rough ETA from the remaining probes' estimates. */
val stepsDone: Int = 0,
val stepsTotal: Int = 0,
val etaSeconds: Int = 0,
/** Shell-tier readiness, shown before a run; null message = say nothing (Shizuku not installed). */
val shizukuNotice: String? = null,
val shizukuReady: Boolean = false,
val shizukuHint: String? = null,
val shizukuState: ShizukuAvailability.State = ShizukuAvailability.State.NOT_INSTALLED,
)
/**
* Drives one measurement run: device-tier probes (link snapshot, per-network ICMP) always run;
* results assemble into a MeasurementDocument with a §7.3 summary. Lives in a ViewModel so a run
* survives rotation — a dropped run means a lost report. Server-facing tests (core-engine) are a
* follow-up once enrollment UI lands.
*/
class RunViewModel(app: Application) : AndroidViewModel(app) {
var state by mutableStateOf(UiState())
private set
private var runJob: kotlinx.coroutines.Job? = null
private val stopShizukuObserver: () -> Unit
init {
// Report shell-tier readiness up front. The binder arrives asynchronously, so this is a
// listener, not a one-shot poll — otherwise a running Shizuku would look "not running"
// for the first moment after launch.
stopShizukuObserver = ShizukuAvailability.observe(app) { st ->
state = state.copy(
shizukuNotice = ShizukuAvailability.describe(st),
shizukuReady = st == ShizukuAvailability.State.READY,
shizukuHint = ShizukuAvailability.actionHint(st),
shizukuState = st,
)
}
}
override fun onCleared() {
stopShizukuObserver()
super.onCleared()
}
// Results collected so far. A cancelled run must still be able to show what it measured.
private val collected = mutableListOf<Test>()
private var runIds: RunIds = RunIds()
private var runStartWall: String = ""
private var runNetworks: List<app.echo_lot.measurement.Network> = emptyList()
private var runShizukuOk = false
/** Two-clock ids: UUIDs + monotonic ns relative to a per-run origin. */
private class RunIds : ProbeIds {
val originNanos = System.nanoTime()
override fun uuid(): String = UUID.randomUUID().toString()
override fun monoNs(): Long = System.nanoTime() - originNanos
}
/**
* Runs one measurement. With [upload] (autorun mode) the finished document is POSTed to the
* collection endpoint so an unattended run can be retrieved without adb.
*/
fun run(upload: Boolean = false) {
if (state.running) return
collected.clear()
state = state.copy(running = true, currentStep = "starting", document = null, uploadStatus = null)
runJob = viewModelScope.launch {
val doc = withContext(Dispatchers.IO) { measure() }
var status: String? = null
if (upload) {
state = state.copy(currentStep = "uploading report")
val r = withContext(Dispatchers.IO) { ReportUploader.upload(doc) }
status = if (r.ok) "uploaded ✓ ${r.detail}" else "upload failed: ${r.detail}"
}
state = UiState(running = false, currentStep = null, document = doc, uploadStatus = status)
}
}
/**
* Stops an in-flight run and shows what was measured so far. Deliberately does NOT upload:
* a partial run is for the person looking at the screen, not for the record.
*/
fun cancel() {
if (!state.running) return
runJob?.cancel()
val doc = buildDocument(collected.toList())
state = UiState(
running = false, currentStep = null, document = doc,
uploadStatus = "cancelled after ${collected.size} test(s) — shown locally, not uploaded",
)
}
private suspend fun measure(): MeasurementDocument {
val ctx = getApplication<Application>()
val ids = RunIds().also { runIds = it }
val startWall = Instant.now().toString().also { runStartWall = it }
step("reading networks")
val entries = NetworkInventory.snapshot(ctx)
val networks = entries.map { it.model }.also { runNetworks = it }
val probes: List<Probe> = listOf(
LinkSnapshotProbe(entries),
RouterIdentityProbe(entries),
IcmpProbe(entries, v6 = false),
IcmpProbe(entries, v6 = true),
CaptivePortalProbe(entries),
// Canary zone served by the Echolot probe server (probe-protocol §6.1). Hardcoded to
// the reference deployment until profiles/enrollment land in the UI.
DnsCanaryProbe(canaryZone = "c.echo-lot.app", sessionPrefix = "adhoc"),
StunProbe(serverHost = "fmr-1.echo-lot.app"),
)
// Plan the run first: the Shizuku battery is counted alongside the app-tier probes so
// the bar reflects the whole run. Estimates are per-probe (see Probe.estimatedMs).
val shizukuEstimateMs = 8_000L
val totalSteps = probes.size + 1
var remainingMs = probes.sumOf { it.estimatedMs } + shizukuEstimateMs
state = state.copy(stepsDone = 0, stepsTotal = totalSteps,
etaSeconds = ((remainingMs + 999) / 1000).toInt())
val tests = ArrayList<Test>()
for ((i, p) in probes.withIndex()) {
step(p.type, done = i, total = totalSteps, etaMs = remainingMs)
val result = (
try {
p.run(ctx, ids)
} catch (t: Throwable) {
Test(
id = ids.uuid(), type = p.type, tier = p.tier,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED,
error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
)
}
)
tests.add(result); collected.add(result)
remainingMs -= p.estimatedMs
}
// Shizuku shell tier — self-degrades to UNSUPPORTED when Shizuku isn't running.
step("link.ip_monitor (shizuku)", done = probes.size, total = totalSteps, etaMs = shizukuEstimateMs)
val shizukuTest = try {
ShizukuProbe().run(ctx, ids::uuid, ids::monoNs)
} catch (t: Throwable) {
Test(
id = ids.uuid(), type = TestType.LINK_IP_MONITOR, tier = Tier.SHIZUKU,
startedMonoNs = ids.monoNs(), endedMonoNs = ids.monoNs(),
status = TestStatus.FAILED, error = TestError("uncaught", t.message ?: t.javaClass.simpleName),
)
}
tests.add(shizukuTest); collected.add(shizukuTest)
runShizukuOk = shizukuTest.status == TestStatus.OK || shizukuTest.status == TestStatus.PARTIAL
return buildDocument(tests)
}
/** Assembles a document from whatever tests are in hand — used for both full and cancelled runs. */
private fun buildDocument(tests: List<Test>): MeasurementDocument {
val findings = deriveFindings(tests, runNetworks)
return MeasurementDocument(
run = Run(
id = runIds.uuid(), trigger = Trigger.MANUAL, startedAt = runStartWall,
endedAt = Instant.now().toString(),
clock = Clock(monoOriginWall = runStartWall),
app = AppInfo(
version = BuildConfig.VERSION_NAME, build = BuildConfig.VERSION_CODE, flavor = "app",
),
device = DeviceInfo(
manufacturer = Build.MANUFACTURER, model = Build.MODEL,
androidSdk = Build.VERSION.SDK_INT, androidRelease = Build.VERSION.RELEASE,
),
tiers = Tiers(app = true, shizuku = runShizukuOk),
),
networks = runNetworks,
tests = tests,
findings = findings,
summary = Verdicts.derive(tests, findings),
)
}
/**
* Was IPv6 actually provisioned on any network? A global (non-link-local) v6 address or a
* v6 default route means the network claims to offer IPv6 — link-local only does not count.
*/
private fun ipv6Provisioned(networks: List<app.echo_lot.measurement.Network>): Boolean =
networks.any { n ->
n.link.addresses.any { a ->
a.addr.contains(':') &&
!a.addr.startsWith("fe80", ignoreCase = true) &&
!a.addr.startsWith("::1")
} || n.link.routes.any { it.dst == "::/0" }
}
/** Minimal first-pass findings from device-tier evidence; the registry grows with the suite. */
private fun deriveFindings(tests: List<Test>, networks: List<app.echo_lot.measurement.Network>): List<Finding> {
val out = ArrayList<Finding>()
val ids = RunIds()
for (t in tests) {
if (t.type == TestType.NET_CAPTIVE_PORTAL) {
val ev = t.evidence?.toString() ?: ""
when {
ev.contains("\"captive_portal\"") -> out.add(
Finding(
id = ids.uuid(), code = "connectivity.captive_portal", category = Category.CONNECTIVITY,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "Captive portal intercepting connections",
description = "The generate_204 check returned a redirect or a page instead of HTTP 204 — a captive portal (login/splash page) is intercepting traffic on this network.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
t.status == TestStatus.FAILED -> out.add(
Finding(
id = ids.uuid(), code = "connectivity.no_internet", category = Category.CONNECTIVITY,
severity = Severity.HIGH, confidence = Confidence.HIGH,
title = "No working internet on any network",
description = "Android's own generate_204 connectivity checks failed on every active network (no HTTP 204) — this device has no validated internet path.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.DNS_CANARY) {
val ev = t.evidence?.toString() ?: ""
if (ev.contains("MISMATCH")) {
out.add(
Finding(
id = ids.uuid(), code = "dns.answer_rewritten", category = Category.DNS,
severity = Severity.HIGH, confidence = Confidence.HIGH,
title = "DNS answers are being rewritten",
description = "A canary reference record returned different RDATA than the spec-defined ground truth — something on the path is rewriting DNS answers (interception, filtering, or a middlebox).",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
} else if (ev.contains("\"reached_authoritative\":false")) {
out.add(
Finding(
id = ids.uuid(), code = "dns.authoritative_unreachable", category = Category.DNS,
severity = Severity.MEDIUM, confidence = Confidence.MEDIUM,
title = "Canary queries don't reach the authoritative server",
description = "A per-run nonce name (which cannot be cached) was not answered by the canary server — the resolver is intercepting or failing to reach it.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.NAT_STUN_5780 && t.status == TestStatus.OK) {
val ev = t.evidence?.toString() ?: ""
if (ev.contains("address/port-dependent (symmetric NAT")) {
out.add(
Finding(
id = ids.uuid(), code = "nat.symmetric", category = Category.NAT,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "Symmetric NAT — peer-to-peer connections need a relay",
description = "The NAT assigns a different external port per destination (address/port-dependent mapping). Direct peer-to-peer connections (calls, games, file transfer) will usually fail and fall back to relays.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
if (t.type == TestType.ICMP_PING6 && t.status == TestStatus.FAILED) {
// A network with no IPv6 at all is NORMAL — most networks are still IPv4-only,
// and that is not a defect. What IS a defect is IPv6 that the network claims to
// provide (a global address or a default route from RA/DHCPv6) but that does not
// work: that causes Happy-Eyeballs delays, timeouts and hangs. So the severity
// depends on whether v6 was provisioned at all.
if (ipv6Provisioned(networks)) {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.broken", category = Category.IPV6,
severity = Severity.MEDIUM, confidence = Confidence.HIGH,
title = "IPv6 is configured but not working",
description = "This network advertises IPv6 (a global address and/or a default route), but ICMPv6 got no reply on any network. Half-configured IPv6 is worse than none: connections try IPv6 first and stall before falling back.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
} else {
out.add(
Finding(
id = ids.uuid(), code = "ipv6.not_offered", category = Category.IPV6,
severity = Severity.INFO, confidence = Confidence.HIGH,
title = "IPv4-only network (no IPv6 offered)",
description = "No IPv6 address or default route was provisioned, so IPv6 tests could not run. This is normal — many networks are still IPv4-only and it is not a fault.",
evidenceRefs = listOf(EvidenceRef(t.id)),
)
)
}
}
}
return out
}
private fun step(s: String, done: Int = state.stepsDone, total: Int = state.stepsTotal, etaMs: Long = -1) {
state = state.copy(
currentStep = s, stepsDone = done, stepsTotal = total,
etaSeconds = if (etaMs >= 0) ((etaMs + 999) / 1000).toInt() else state.etaSeconds,
)
}
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon background: the brand "tile" gradient (assets/branding). -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:pathData="M0,0h108v108h-108z">
<aapt:attr name="android:fillColor">
<gradient android:startX="54" android:startY="0" android:endX="54" android:endY="108"
android:type="linear">
<item android:offset="0" android:color="#FF0E2433"/>
<item android:offset="1" android:color="#FF071522"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Adaptive-icon foreground: the Focus mark (assets/branding/icon-adaptive-foreground.svg),
SVG transform baked in so the art sits inside the 66dp safe circle. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportHeight="108">
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,34.8 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,54.0 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M32.72,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M51.92,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:fillColor="#FF1E4A5C" android:pathData="M71.12,73.2 a2.08,2.08 0 1,0 4.16,0 a2.08,2.08 0 1,0 -4.16,0"/>
<path android:strokeColor="#FFB454" android:strokeAlpha="0.3" android:strokeWidth="1.6" android:fillColor="#00000000" android:pathData="M47.6,54.0 a6.4,6.4 0 1,0 12.8,0 a6.4,6.4 0 1,0 -12.8,0"/>
<path android:fillColor="#FFFFB454" android:pathData="M50.4,54.0 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M42.0,48.4 V42.0 H48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M59.6,42.0 H66.0 V48.4"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M66.0,59.6 V66.0 H59.6"/>
<path android:strokeColor="#FF35E0C4" android:strokeWidth="2.8" android:strokeLineCap="round" android:strokeLineJoin="round" android:fillColor="#00000000" android:pathData="M48.4,66.0 H42.0 V59.6"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
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
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The measurement run engine: composes core-protocol probes into
// core-measurement documents. Pure Kotlin/JVM, so it is unit-testable and can
// run a full server-facing measurement against a live server.
dependencies {
implementation(project(":core-protocol"))
implementation(project(":core-measurement"))
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test {
useJUnitPlatform()
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET")
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
}
@@ -0,0 +1,166 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package engine composes core-protocol probes into core-measurement documents — the run engine
// the app drives. This file covers the server-facing vertical (control plane + UDP data plane);
// device-tier probes (link snapshot, Shizuku, local discovery) plug in from the Android modules.
package app.echo_lot.engine
import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* Runs the server-facing measurements against one target and assembles a [MeasurementDocument]:
* an ECHO train (RTT distribution, loss, and NAT-rebinding detection from the server's observed
* source port) as `train.udp_updown`. Everything is real evidence with recomputable metrics, and
* findings are derived deterministically. IDs/timestamps are injected so the engine stays pure
* (no clocks/UUIDs of its own) and unit-testable.
*/
class ServerMeasurement(
private val ids: IdSource,
private val app: AppInfo,
private val device: DeviceInfo,
) {
private val json = Json { encodeDefaults = true; explicitNulls = true }
data class Config(
val controlUrl: String,
val pins: Set<String>,
val credential: String,
val target: String,
val udpHost: String,
val udpPort: Int,
val echoCount: Int = 20,
val echoPaddingBytes: Int = 64,
)
fun run(cfg: Config): MeasurementDocument {
val runId = ids.uuid()
val startWall = ids.nowWall()
val startMono = ids.monoNs()
val control = ControlClient(cfg.controlUrl, cfg.pins)
val profile = control.profile(cfg.credential)
val session = control.createSession(cfg.credential, cfg.target)
val serverSession = ServerSession(
id = "sess-1",
profileName = profile.name,
controlUrl = cfg.controlUrl,
serverVersion = profile.serverVersion,
capabilities = profile.capabilities,
sessionId = session.sessionId,
target = SessionTarget(ip4 = cfg.udpHost, udpPort = cfg.udpPort),
)
val (test, findings) = echoTrain(cfg, control, session, startMono)
control.deleteSession(cfg.credential, session.sessionId)
val summary = Verdicts.derive(listOf(test), findings)
return MeasurementDocument(
run = Run(
id = runId, trigger = Trigger.MANUAL, startedAt = startWall, endedAt = ids.nowWall(),
clock = Clock(monoOriginWall = startWall),
app = app, device = device,
tiers = Tiers(app = true),
),
serverSessions = listOf(serverSession),
tests = listOf(test),
findings = findings,
summary = summary,
)
}
private fun echoTrain(
cfg: Config, control: ControlClient, session: app.echo_lot.protocol.SessionResponse, startMono: Long,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val seqs = ArrayList<Int>()
val tTx = ArrayList<Long?>()
val tRx = ArrayList<Long?>()
val sizes = ArrayList<Int>()
val rtts = ArrayList<Double>()
val observedPorts = LinkedHashSet<Int>()
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes)
seqs.add(i)
tTx.add(txMono)
sizes.add(Wire_HEADER + cfg.echoPaddingBytes)
if (r != null) {
tRx.add(ids.monoNs() - startMono)
rtts.add(r.rttMs)
r.observation?.observedPort?.let { observedPorts.add(it) }
} else {
tRx.add(null)
}
}
}
val sent = cfg.echoCount
val received = rtts.size
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
val natRebinding = observedPorts.size > 1
val evidence: JsonObject = TrainEvidence(
epochMonoNs = startMono, seq = seqs, tTxNs = tTx, tRxNs = tRx, sizeBytes = sizes,
).toEvidence()
val metrics: JsonObject = json.encodeToJsonElement(
EchoMetrics(
sent = sent, received = received, lossPct = round1(lossPct),
rttMsMin = rtts.minOrNull()?.let(::round1),
rttMsAvg = rtts.average().takeIf { received > 0 }?.let(::round1),
rttMsMax = rtts.maxOrNull()?.let(::round1),
observedPorts = observedPorts.toList(),
natRebindingDetected = natRebinding,
)
) as JsonObject
val status = when {
received == 0 -> TestStatus.FAILED
received < sent -> TestStatus.PARTIAL
else -> TestStatus.OK
}
val test = Test(
id = testId, type = TestType.TRAIN_UDP_UPDOWN, sessionRef = "sess-1", tier = Tier.APP,
startedMonoNs = startMono, endedMonoNs = ids.monoNs(), status = status,
evidence = evidence, metrics = metrics,
)
val findings = ArrayList<Finding>()
if (received == 0) {
findings.add(finding("nat.udp_unreachable", Category.CONNECTIVITY, Severity.HIGH, testId,
"No UDP echo replies from the server",
"Every ECHO probe to the server's UDP data plane was lost — the path blocks or drops the session's UDP traffic."))
} else if (lossPct >= 20.0) {
findings.add(finding("connectivity.udp_loss", Category.CONNECTIVITY, Severity.MEDIUM, testId,
"High UDP loss to the server (${round1(lossPct)}%)",
"A large fraction of ECHO probes were lost, indicating an unreliable UDP path."))
}
if (natRebinding) {
findings.add(finding("nat.udp_rebinding", Category.NAT, Severity.MEDIUM, testId,
"NAT remapped the UDP source port mid-flow",
"The server observed more than one source port for this session (${observedPorts.joinToString()}), i.e. a NAT with a short UDP mapping or per-packet remapping."))
}
return test to findings
}
private fun finding(code: String, cat: Category, sev: Severity, testId: String, title: String, desc: String) =
Finding(
id = ids.uuid(), code = code, category = cat, severity = sev, confidence = Confidence.HIGH,
title = title, description = desc, evidenceRefs = listOf(EvidenceRef(testId)),
)
private companion object {
const val Wire_HEADER = 32
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
@@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.Instant
import java.util.UUID
/**
* Clock/ID source, injected so the engine has no hidden nondeterminism and stays unit-testable.
* The default uses wall + monotonic clocks and random UUIDs; tests supply deterministic ones.
*/
interface IdSource {
fun uuid(): String
fun monoNs(): Long
fun nowWall(): String
}
class SystemIdSource : IdSource {
override fun uuid(): String = UUID.randomUUID().toString()
override fun monoNs(): Long = System.nanoTime()
override fun nowWall(): String = Instant.now().toString()
}
/** Metrics for train.udp_updown; recomputable from the columnar evidence. */
@Serializable
data class EchoMetrics(
val sent: Int,
val received: Int,
@SerialName("loss_pct") val lossPct: Double,
@SerialName("rtt_ms_min") val rttMsMin: Double? = null,
@SerialName("rtt_ms_avg") val rttMsAvg: Double? = null,
@SerialName("rtt_ms_max") val rttMsMax: Double? = null,
@SerialName("observed_ports") val observedPorts: List<Int> = emptyList(),
@SerialName("nat_rebinding_detected") val natRebindingDetected: Boolean = false,
)
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
*
* This is the direction a client cannot measure alone — only the far end can push large or
* numerous packets toward it — so it is also the direction that needs the anti-amplification
* grant, and this test is the proof that the grant path works end to end.
*/
class LiveGrantedTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun downstreamTrainAndBigSend() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("capabilities: ${profile.capabilities}")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
val echo = ps.echo()
println("primed with echo rtt=${echo?.rttMs}")
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
val dtResp = control.action(
cred, session.sessionId,
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
)
println("downtrain accepted: ${dtResp.take(160)}")
val down = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val seqs = down.map { it.seq }.toSet()
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
"sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send: which downstream sizes survive? ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val bsResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","sizes_bytes":${sizes}}""",
)
println("big_send accepted: ${bsResp.take(160)}")
val big = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }
val arrived = big.map { it.sizeBytes }.sorted()
println("big_send arrived sizes: $arrived (requested $sizes)")
assertTrue(big.isNotEmpty(), "no BIG_SEND packets arrived")
println("largest downstream datagram delivered: ${arrived.maxOrNull()}")
}
control.deleteSession(cred, session.sessionId)
}
}
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.*
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Runs the full server-facing engine against a live server and validates the produced
* MeasurementDocument. Self-skips without ECHOLOT_LIVE_* (same contract as core-protocol's live
* test). This is the whole vertical: protocol client → engine → schema document → verdict.
*/
class LiveMeasurementTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun producesValidDocumentFromLiveServer() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveMeasurementTest skipped (no ECHOLOT_LIVE_* env)")
return
}
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val engine = ServerMeasurement(
ids = SystemIdSource(),
app = AppInfo(version = "0.1.0", build = 1, flavor = "test"),
device = DeviceInfo("test", "jvm", 0, "n/a"),
)
val doc = engine.run(
ServerMeasurement.Config(
controlUrl = url, pins = setOf(pin), credential = cred,
target = target, udpHost = host, udpPort = port, echoCount = 20,
)
)
// The document must round-trip and carry the expected structure.
val encoded = Json { encodeDefaults = true }.encodeToString(MeasurementDocument.serializer(), doc)
println("document (${encoded.length} bytes): overall=${doc.summary?.overall}")
assertEquals(1, doc.serverSessions.size)
assertTrue(doc.serverSessions[0].capabilities.contains("udp-probe"))
val test = doc.tests.single()
assertEquals(TestType.TRAIN_UDP_UPDOWN, test.type)
assertTrue(test.status == TestStatus.OK || test.status == TestStatus.PARTIAL,
"expected replies from live server, got ${test.status}")
val metrics = Json.parseToJsonElement(test.metrics.toString())
println("metrics: $metrics")
assertTrue(metrics.toString().contains("rtt_ms_avg"))
assertTrue(doc.summary != null)
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
println("summary: ${doc.summary}")
}
}
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package measurement models one measurement run (measurement-schema.md) — the archived,
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class MeasurementDocument(
val schema: String = "echolot/measurement",
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
val run: Run,
val networks: List<Network> = emptyList(),
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
val tests: List<Test> = emptyList(),
val findings: List<Finding> = emptyList(),
val summary: Summary? = null,
)
@Serializable
data class Run(
val id: String, // UUIDv7
val trigger: Trigger,
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
@SerialName("ended_at") val endedAt: String? = null,
val clock: Clock,
val app: AppInfo,
val device: DeviceInfo,
val tiers: Tiers,
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
val notes: String? = null,
)
@Serializable
enum class Trigger {
@SerialName("manual") MANUAL,
@SerialName("scheduled") SCHEDULED,
@SerialName("monitor") MONITOR,
@SerialName("peer") PEER,
}
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
* all math uses *_mono_ns relative to that monotonic origin. */
@Serializable
data class Clock(
@SerialName("mono_origin_wall") val monoOriginWall: String,
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
)
@Serializable
data class AppInfo(
val version: String,
val build: Int,
val git: String? = null,
val flavor: String? = null,
)
@Serializable
data class DeviceInfo(
val manufacturer: String,
val model: String,
@SerialName("android_sdk") val androidSdk: Int,
@SerialName("android_release") val androidRelease: String,
@SerialName("security_patch") val securityPatch: String? = null,
)
/** What each tier was *available*; each test records what it *used*. */
@Serializable
data class Tiers(
val app: Boolean = true,
val shizuku: Boolean = false,
val root: Boolean = false,
)
@Serializable
data class ServerSession(
val id: String,
@SerialName("profile_id") val profileId: String? = null,
@SerialName("profile_name") val profileName: String? = null,
@SerialName("control_url") val controlUrl: String,
@SerialName("server_version") val serverVersion: String? = null,
val capabilities: List<String> = emptyList(),
@SerialName("session_id") val sessionId: String,
val target: SessionTarget,
)
@Serializable
data class SessionTarget(
val ip4: String? = null,
val ip6: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
)
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
* means the columnar/traceroute/resolver contracts live in one place.
*/
@PublishedApi
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
inline fun <reified T> T.toEvidence(): JsonObject =
evidenceJson.encodeToJsonElement(this) as JsonObject
/**
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
* columns use the server session epoch; only differences within one clock are meaningful unless a
* time.server_offset test maps them.
*/
@Serializable
data class TrainEvidence(
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
val seq: List<Int>,
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
@SerialName("size_bytes") val sizeBytes: List<Int>,
@SerialName("dscp_sent") val dscpSent: Int? = null,
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
@SerialName("ecn_sent") val ecnSent: Int? = null,
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
)
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
@Serializable
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
@Serializable
data class Flow(
@SerialName("src_port") val srcPort: Int,
@SerialName("dst_port") val dstPort: Int,
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
)
@Serializable
data class Hop(val ttl: Int, val probes: List<HopProbe>)
@Serializable
data class HopProbe(
@SerialName("reply_from") val replyFrom: String? = null,
@SerialName("rtt_ns") val rttNs: Long? = null,
val icmp: String? = null,
@SerialName("reply_ttl") val replyTtl: Int? = null,
)
/** Resolver under test (§6.4); every dns.* test carries this in params. */
@Serializable
data class ResolverSpec(
val source: ResolverSource,
val address: String? = null,
val port: Int = 53,
val transport: String, // do53-udp | do53-tcp | dot | doh
@SerialName("doh_url") val dohUrl: String? = null,
)
@Serializable
enum class ResolverSource {
@SerialName("system") SYSTEM,
@SerialName("manual") MANUAL,
@SerialName("server-recursive") SERVER_RECURSIVE,
}
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
@Serializable
data class Finding(
val id: String, // UUIDv7
val code: String, // stable registry (findings-registry.md), lint-rule style
val category: Category,
val severity: Severity,
val confidence: Confidence,
@SerialName("network_ref") val networkRef: String? = null,
val title: String,
val description: String,
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
val recommendation: String? = null,
) {
init {
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
}
}
@Serializable
data class EvidenceRef(val test: String, val pointer: String? = null)
/** Fixed §7.2 categories; each maps to one traffic light. */
@Serializable
enum class Category {
@SerialName("connectivity") CONNECTIVITY,
@SerialName("dns") DNS,
@SerialName("nat") NAT,
@SerialName("mtu") MTU,
@SerialName("ipv6") IPV6,
@SerialName("security") SECURITY,
@SerialName("performance") PERFORMANCE,
@SerialName("local") LOCAL,
@SerialName("wifi") WIFI,
}
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
@Serializable
enum class Severity(val rank: Int) {
@SerialName("critical") CRITICAL(4),
@SerialName("high") HIGH(3),
@SerialName("medium") MEDIUM(2),
@SerialName("low") LOW(1),
@SerialName("info") INFO(0);
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
fun toLight(): Verdict = when (this) {
CRITICAL, HIGH -> Verdict.RED
MEDIUM, LOW -> Verdict.YELLOW
INFO -> Verdict.GREEN
}
}
@Serializable
enum class Confidence {
@SerialName("high") HIGH,
@SerialName("medium") MEDIUM,
@SerialName("low") LOW,
}
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
@Serializable
data class Network(
val id: String,
val transport: Transport,
@SerialName("interface") val iface: String? = null,
val link: Link,
val wifi: Wifi? = null,
val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(),
)
@Serializable
enum class Transport {
@SerialName("wifi") WIFI,
@SerialName("cellular") CELLULAR,
@SerialName("ethernet") ETHERNET,
@SerialName("vpn") VPN,
@SerialName("other") OTHER,
}
@Serializable
data class Link(
val mtu: Int? = null,
val addresses: List<Address> = emptyList(),
val routes: List<Route> = emptyList(),
val dns: DnsConfig? = null,
val dhcp: Dhcp? = null,
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
)
@Serializable
data class Address(
val addr: String, // ip4 | ip6 (logical type, §8)
@SerialName("prefix_len") val prefixLen: Int,
val scope: String? = null,
val flags: List<String> = emptyList(),
@SerialName("valid_lft_s") val validLftS: Long? = null,
@SerialName("pref_lft_s") val prefLftS: Long? = null,
)
@Serializable
data class Route(
val dst: String,
val gateway: String? = null,
val iface: String? = null,
val proto: RouteProto? = null, // shizuku tier; null = not observed
@SerialName("expires_s") val expiresS: Long? = null,
)
@Serializable
enum class RouteProto {
@SerialName("dhcp") DHCP,
@SerialName("ra") RA,
@SerialName("static") STATIC,
@SerialName("unknown") UNKNOWN,
}
@Serializable
data class DnsConfig(
val servers: List<String> = emptyList(),
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
)
@Serializable
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
@Serializable
data class CaptivePortal(
val detected: Boolean = false,
@SerialName("api_url") val apiUrl: String? = null,
@SerialName("venue_url") val venueUrl: String? = null,
)
@Serializable
data class Wifi(
val ssid: String? = null, // ssid (logical type)
val bssid: String? = null, // bssid (logical type)
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
val standard: String? = null,
val security: String? = null,
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
)
@Serializable
data class Cellular(
val rat: String? = null,
val operator: String? = null,
val band: String? = null,
)
@Serializable
data class NetworkChange(
@SerialName("at_mono_ns") val atMonoNs: Long,
val kind: String, // lost | gained | link_changed
val detail: JsonObject? = null,
)
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class Verdict {
@SerialName("green") GREEN,
@SerialName("yellow") YELLOW,
@SerialName("red") RED,
@SerialName("inconclusive") INCONCLUSIVE,
}
@Serializable
data class Summary(
val overall: Verdict,
val categories: Map<String, CategorySummary>,
)
@Serializable
data class CategorySummary(
val verdict: Verdict,
@SerialName("worst_finding") val worstFinding: String? = null,
@SerialName("tests_run") val testsRun: Int,
@SerialName("tests_failed") val testsFailed: Int,
)
/**
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
*
* - A category's verdict = the light of its worst-severity finding
* (critical|high → red, medium|low → yellow, info/none → green).
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
*
* The mapping test-type → category comes from [TestType.category]. Only categories that have
* findings or tests appear in the summary.
*/
object Verdicts {
private fun isInconclusiveTest(s: TestStatus) =
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
val testsByCat = tests.groupBy { TestType.category(it.type) }
val findingsByCat = findings.groupBy { it.category }
val categories = (testsByCat.keys + findingsByCat.keys)
val perCat = LinkedHashMap<String, CategorySummary>()
for (cat in Category.entries) {
if (cat !in categories) continue
val catTests = testsByCat[cat].orEmpty()
val catFindings = findingsByCat[cat].orEmpty()
val failed = catTests.count { isInconclusiveTest(it.status) }
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
val worst = catFindings.maxByOrNull { it.severity.rank }
val verdict = when {
inconclusive -> Verdict.INCONCLUSIVE
worst == null -> Verdict.GREEN
else -> worst.severity.toLight()
}
perCat[serialName(cat)] = CategorySummary(
verdict = verdict,
worstFinding = worst?.id,
testsRun = catTests.size,
testsFailed = failed,
)
}
val overall = deriveOverall(perCat.values)
return Summary(overall = overall, categories = perCat)
}
/** Overall = worst light; inconclusive only if every category is inconclusive. */
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
// Non-inconclusive categories decide the overall light.
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
}
private fun serialName(cat: Category): String = cat.name.lowercase()
}
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
* per-test-type JSON, so they are carried as JsonObject. */
@Serializable
data class Test(
val id: String, // UUIDv7
val type: String, // TestType registry (§6.1)
@SerialName("network_ref") val networkRef: String? = null,
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
val tier: Tier,
@SerialName("started_mono_ns") val startedMonoNs: Long,
@SerialName("ended_mono_ns") val endedMonoNs: Long,
val status: TestStatus,
val error: TestError? = null,
val params: JsonObject? = null,
val evidence: JsonObject? = null,
val metrics: JsonObject? = null,
)
@Serializable
enum class Tier {
@SerialName("app") APP,
@SerialName("shizuku") SHIZUKU,
@SerialName("root") ROOT,
}
@Serializable
enum class TestStatus {
@SerialName("ok") OK,
@SerialName("failed") FAILED,
@SerialName("unsupported") UNSUPPORTED,
@SerialName("skipped") SKIPPED,
@SerialName("partial") PARTIAL,
}
@Serializable
data class TestError(val code: String, val detail: String? = null)
/**
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
* the fixed §7.2 categories for verdict rollup.
*/
object TestType {
// link
const val LINK_SNAPSHOT = "link.snapshot"
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
const val LINK_IP_MONITOR = "link.ip_monitor"
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
const val LINK_RA_SOURCE = "link.ra_source"
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
// icmp
const val ICMP_PING4 = "icmp.ping4"
const val ICMP_PING6 = "icmp.ping6"
// trace
const val TRACEROUTE_UDP4 = "traceroute.udp4"
const val TRACEROUTE_UDP6 = "traceroute.udp6"
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
// train
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
// mtu
const val MTU_PMTUD_UP = "mtu.pmtud_up"
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
const val MTU_BLACKHOLE = "mtu.blackhole"
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
// nat
const val NAT_STUN_5780 = "nat.stun_5780"
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
const val NAT_HAIRPIN = "nat.hairpin"
const val NAT_CONNECT_BACK = "nat.connect_back"
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
// dns
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
const val DNS_CANARY = "dns.canary"
const val DNS_INTERCEPTION = "dns.interception"
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
const val DNS_DNSSEC = "dns.dnssec"
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
const val DNS_REBIND_FILTER = "dns.rebind_filter"
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
const val DNS_DNS64 = "dns.dns64"
const val DNS_COMPARE = "dns.compare"
// sec
const val SEC_TLS_REFERENCE = "sec.tls_reference"
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
const val SEC_HTTP_ECHO = "sec.http_echo"
const val SEC_SNI_FILTER = "sec.sni_filter"
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
const val SEC_ARP_WATCH = "sec.arp_watch"
// port
const val PORT_REACH_SWEEP = "port.reach_sweep"
const val PORT_UDP_USABILITY = "port.udp_usability"
// perf
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
const val PERF_RRC_LATENCY = "perf.rrc_latency"
// v6
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
const val V6_BROKENNESS = "v6.brokenness"
const val V6_NAT64_CLAT = "v6.nat64_clat"
// wifi
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
const val WIFI_ROAM_LOG = "wifi.roam_log"
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
// local
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
const val PEER_REACHABILITY = "peer.reachability"
const val PEER_ISOLATION = "peer.isolation"
const val PEER_MULTICAST = "peer.multicast"
const val PEER_LAN_TRAIN = "peer.lan_train"
const val PEER_LEASE_DIFF = "peer.lease_diff"
// time
const val TIME_SERVER_OFFSET = "time.server_offset"
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
fun category(type: String): Category = when (type.substringBefore('.')) {
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
"dns" -> Category.DNS
"nat" -> Category.NAT
"mtu" -> Category.MTU
"v6" -> Category.IPV6
"sec" -> Category.SECURITY
"perf" -> Category.PERFORMANCE
"local", "peer" -> Category.LOCAL
"wifi" -> Category.WIFI
else -> Category.CONNECTIVITY
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.json.Json
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SerializationTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@JTest
fun documentRoundTrips() {
val doc = MeasurementDocument(
run = Run(
id = "0198c5f2-0000-7000-8000-000000000000",
trigger = Trigger.MANUAL,
startedAt = "2026-07-31T14:03:21.114Z",
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
app = AppInfo(version = "0.1.0", build = 1),
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
tiers = Tiers(app = true, shizuku = true),
),
networks = listOf(
Network(
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
wifi = Wifi(ssid = "example", rssiDbm = -54),
),
),
tests = listOf(
Test(
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
evidence = TrainEvidence(
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
).toEvidence(),
),
),
)
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
assertEquals(doc.run.id, decoded.run.id)
assertEquals(Transport.WIFI, decoded.networks[0].transport)
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
// snake_case field names on the wire
assertTrue(encoded.contains("\"schema_version\""))
assertTrue(encoded.contains("\"mono_origin_wall\""))
assertTrue(encoded.contains("\"t_tx_ns\""))
// null preserved at train index 1
assertTrue(encoded.contains("[16500000,null]"))
}
@JTest
fun findingRequiresEvidence() {
try {
Finding(
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
)
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
} catch (e: IllegalArgumentException) {
// expected — a finding with no evidence is invalid (§7.1)
}
}
}
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
class VerdictsTest {
private fun test(type: String, status: TestStatus, id: String = type): Test =
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
Finding(
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
)
@JTest
fun categoryLightFromWorstSeverity() {
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
val findings = listOf(
finding(Category.DNS, Severity.LOW),
finding(Category.DNS, Severity.HIGH), // worst → red
finding(Category.DNS, Severity.INFO),
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun noFindingsIsGreen() {
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall)
}
@JTest
fun mediumAndLowAreYellow() {
val s = Verdicts.derive(
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
)
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
}
@JTest
fun majorityFailedIsInconclusive() {
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(2, s.categories["dns"]!!.testsFailed)
assertEquals(3, s.categories["dns"]!!.testsRun)
}
@JTest
fun exactlyHalfFailedIsNotInconclusive() {
// 1 of 2 failed → not > 50% → the finding decides.
val tests = listOf(
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
}
@JTest
fun overallIsWorstCategory() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
)
val findings = listOf(
finding(Category.DNS, Severity.MEDIUM), // yellow
finding(Category.MTU, Severity.CRITICAL), // red
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun overallInconclusiveOnlyWhenAllAre() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
)
val s = Verdicts.derive(tests, emptyList())
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
}
@JTest
fun categoryMappingCoversFamilies() {
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
}
}
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
// the Android app modules can depend on it and it stays unit-testable (incl.
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
// Build with the available JDK (Android Studio's JBR is 21) but emit
// Java-17 bytecode so the Android app modules can consume this library.
jvmToolchain(21)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
}
@@ -0,0 +1,100 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package measurement models one measurement run (measurement-schema.md) — the archived,
// diffable, exportable unit. Design rules honored in the types: observation/interpretation
// separated (tests[] vs findings[]), two clocks (wall RFC3339 for humans, *_mono_ns for math),
// units in field names, columnar trains. params/evidence/metrics are per-test-type, so they are
// carried as JsonObject (the probe engine fills them; consumers ignore unknown fields).
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class MeasurementDocument(
val schema: String = "echolot/measurement",
@SerialName("schema_version") val schemaVersion: String = "1.0.0",
val run: Run,
val networks: List<Network> = emptyList(),
@SerialName("server_sessions") val serverSessions: List<ServerSession> = emptyList(),
val tests: List<Test> = emptyList(),
val findings: List<Finding> = emptyList(),
val summary: Summary? = null,
)
@Serializable
data class Run(
val id: String, // UUIDv7
val trigger: Trigger,
@SerialName("started_at") val startedAt: String, // RFC3339 UTC, human correlation only
@SerialName("ended_at") val endedAt: String? = null,
val clock: Clock,
val app: AppInfo,
val device: DeviceInfo,
val tiers: Tiers,
@SerialName("profiles_used") val profilesUsed: List<String> = emptyList(),
val notes: String? = null,
)
@Serializable
enum class Trigger {
@SerialName("manual") MANUAL,
@SerialName("scheduled") SCHEDULED,
@SerialName("monitor") MONITOR,
@SerialName("peer") PEER,
}
/** The two-clock anchor: mono_origin_wall maps the monotonic epoch to a wall time for humans;
* all math uses *_mono_ns relative to that monotonic origin. */
@Serializable
data class Clock(
@SerialName("mono_origin_wall") val monoOriginWall: String,
@SerialName("ntp_offset_ms") val ntpOffsetMs: Double? = null,
@SerialName("ntp_offset_source") val ntpOffsetSource: String? = null,
)
@Serializable
data class AppInfo(
val version: String,
val build: Int,
val git: String? = null,
val flavor: String? = null,
)
@Serializable
data class DeviceInfo(
val manufacturer: String,
val model: String,
@SerialName("android_sdk") val androidSdk: Int,
@SerialName("android_release") val androidRelease: String,
@SerialName("security_patch") val securityPatch: String? = null,
)
/** What each tier was *available*; each test records what it *used*. */
@Serializable
data class Tiers(
val app: Boolean = true,
val shizuku: Boolean = false,
val root: Boolean = false,
)
@Serializable
data class ServerSession(
val id: String,
@SerialName("profile_id") val profileId: String? = null,
@SerialName("profile_name") val profileName: String? = null,
@SerialName("control_url") val controlUrl: String,
@SerialName("server_version") val serverVersion: String? = null,
val capabilities: List<String> = emptyList(),
@SerialName("session_id") val sessionId: String,
val target: SessionTarget,
)
@Serializable
data class SessionTarget(
val ip4: String? = null,
val ip6: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
)
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* Typed builders for the per-test-type evidence shapes the schema fixes (§6.2/§6.3/§6.4). Probe
* code fills these and folds them into [Test.evidence] via [toEvidence]; keeping them typed here
* means the columnar/traceroute/resolver contracts live in one place.
*/
@PublishedApi
internal val evidenceJson = Json { encodeDefaults = true; explicitNulls = true }
/** Serialize any typed evidence object into the JsonObject the Test envelope carries. */
inline fun <reified T> T.toEvidence(): JsonObject =
evidenceJson.encodeToJsonElement(this) as JsonObject
/**
* Packet-train evidence (§6.2): columnar parallel arrays, one index per probe packet. Missing
* observations are null at that index — a 10k-packet train stays in the hundreds of kB. Server
* columns use the server session epoch; only differences within one clock are meaningful unless a
* time.server_offset test maps them.
*/
@Serializable
data class TrainEvidence(
@SerialName("epoch_mono_ns") val epochMonoNs: Long,
val seq: List<Int>,
@SerialName("t_tx_ns") val tTxNs: List<Long?>,
@SerialName("t_srv_rx_ns") val tSrvRxNs: List<Long?> = emptyList(),
@SerialName("t_srv_tx_ns") val tSrvTxNs: List<Long?> = emptyList(),
@SerialName("t_rx_ns") val tRxNs: List<Long?>,
@SerialName("size_bytes") val sizeBytes: List<Int>,
@SerialName("dscp_sent") val dscpSent: Int? = null,
@SerialName("dscp_seen_by_server") val dscpSeenByServer: List<Int?> = emptyList(),
@SerialName("ecn_sent") val ecnSent: Int? = null,
@SerialName("ecn_seen_by_server") val ecnSeenByServer: List<Int?> = emptyList(),
@SerialName("ttl_seen_by_server") val ttlSeenByServer: List<Int?> = emptyList(),
@SerialName("evidence_truncated") val evidenceTruncated: Boolean = false,
)
/** Traceroute evidence (§6.3): fixed-tuple flow + per-TTL probe replies. */
@Serializable
data class TracerouteEvidence(val flow: Flow, val hops: List<Hop>)
@Serializable
data class Flow(
@SerialName("src_port") val srcPort: Int,
@SerialName("dst_port") val dstPort: Int,
@SerialName("fixed_tuple") val fixedTuple: Boolean = true,
)
@Serializable
data class Hop(val ttl: Int, val probes: List<HopProbe>)
@Serializable
data class HopProbe(
@SerialName("reply_from") val replyFrom: String? = null,
@SerialName("rtt_ns") val rttNs: Long? = null,
val icmp: String? = null,
@SerialName("reply_ttl") val replyTtl: Int? = null,
)
/** Resolver under test (§6.4); every dns.* test carries this in params. */
@Serializable
data class ResolverSpec(
val source: ResolverSource,
val address: String? = null,
val port: Int = 53,
val transport: String, // do53-udp | do53-tcp | dot | doh
@SerialName("doh_url") val dohUrl: String? = null,
)
@Serializable
enum class ResolverSource {
@SerialName("system") SYSTEM,
@SerialName("manual") MANUAL,
@SerialName("server-recursive") SERVER_RECURSIVE,
}
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Interpretation with references back to evidence (measurement-schema.md §7.1). A finding with
* no evidence_refs is invalid — every finding must be re-derivable from the evidence alone. */
@Serializable
data class Finding(
val id: String, // UUIDv7
val code: String, // stable registry (findings-registry.md), lint-rule style
val category: Category,
val severity: Severity,
val confidence: Confidence,
@SerialName("network_ref") val networkRef: String? = null,
val title: String,
val description: String,
@SerialName("evidence_refs") val evidenceRefs: List<EvidenceRef>,
val recommendation: String? = null,
) {
init {
require(evidenceRefs.isNotEmpty()) { "a finding must reference at least one piece of evidence" }
}
}
@Serializable
data class EvidenceRef(val test: String, val pointer: String? = null)
/** Fixed §7.2 categories; each maps to one traffic light. */
@Serializable
enum class Category {
@SerialName("connectivity") CONNECTIVITY,
@SerialName("dns") DNS,
@SerialName("nat") NAT,
@SerialName("mtu") MTU,
@SerialName("ipv6") IPV6,
@SerialName("security") SECURITY,
@SerialName("performance") PERFORMANCE,
@SerialName("local") LOCAL,
@SerialName("wifi") WIFI,
}
/** Ordered worst→best via [rank]; drives the §7.3 light mapping. */
@Serializable
enum class Severity(val rank: Int) {
@SerialName("critical") CRITICAL(4),
@SerialName("high") HIGH(3),
@SerialName("medium") MEDIUM(2),
@SerialName("low") LOW(1),
@SerialName("info") INFO(0);
/** §7.3: critical|high → red, medium|low → yellow, info → green. */
fun toLight(): Verdict = when (this) {
CRITICAL, HIGH -> Verdict.RED
MEDIUM, LOW -> Verdict.YELLOW
INFO -> Verdict.GREEN
}
}
@Serializable
enum class Confidence {
@SerialName("high") HIGH,
@SerialName("medium") MEDIUM,
@SerialName("low") LOW,
}
@@ -0,0 +1,113 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** One Android Network in play (measurement-schema.md §4). Shizuku-tier fields (route proto,
* lifetimes) are absent at app tier — absence means "not observed", never "not present". */
@Serializable
data class Network(
val id: String,
val transport: Transport,
@SerialName("interface") val iface: String? = null,
val link: Link,
val wifi: Wifi? = null,
val cellular: Cellular? = null,
val changes: List<NetworkChange> = emptyList(),
)
@Serializable
enum class Transport {
@SerialName("wifi") WIFI,
@SerialName("cellular") CELLULAR,
@SerialName("ethernet") ETHERNET,
@SerialName("vpn") VPN,
@SerialName("other") OTHER,
}
@Serializable
data class Link(
val mtu: Int? = null,
val addresses: List<Address> = emptyList(),
val routes: List<Route> = emptyList(),
val dns: DnsConfig? = null,
val dhcp: Dhcp? = null,
@SerialName("captive_portal") val captivePortal: CaptivePortal? = null,
)
@Serializable
data class Address(
val addr: String, // ip4 | ip6 (logical type, §8)
@SerialName("prefix_len") val prefixLen: Int,
val scope: String? = null,
val flags: List<String> = emptyList(),
@SerialName("valid_lft_s") val validLftS: Long? = null,
@SerialName("pref_lft_s") val prefLftS: Long? = null,
)
@Serializable
data class Route(
val dst: String,
val gateway: String? = null,
val iface: String? = null,
val proto: RouteProto? = null, // shizuku tier; null = not observed
@SerialName("expires_s") val expiresS: Long? = null,
)
@Serializable
enum class RouteProto {
@SerialName("dhcp") DHCP,
@SerialName("ra") RA,
@SerialName("static") STATIC,
@SerialName("unknown") UNKNOWN,
}
@Serializable
data class DnsConfig(
val servers: List<String> = emptyList(),
@SerialName("private_dns_mode") val privateDnsMode: String? = null,
@SerialName("private_dns_hostname") val privateDnsHostname: String? = null,
@SerialName("search_domains") val searchDomains: List<String> = emptyList(),
@SerialName("nat64_prefix") val nat64Prefix: String? = null,
)
@Serializable
data class Dhcp(val server: String? = null, @SerialName("lease_s") val leaseS: Long? = null)
@Serializable
data class CaptivePortal(
val detected: Boolean = false,
@SerialName("api_url") val apiUrl: String? = null,
@SerialName("venue_url") val venueUrl: String? = null,
)
@Serializable
data class Wifi(
val ssid: String? = null, // ssid (logical type)
val bssid: String? = null, // bssid (logical type)
@SerialName("rssi_dbm") val rssiDbm: Int? = null,
@SerialName("link_speed_mbps") val linkSpeedMbps: Int? = null,
@SerialName("frequency_mhz") val frequencyMhz: Int? = null,
@SerialName("channel_width_mhz") val channelWidthMhz: Int? = null,
val standard: String? = null,
val security: String? = null,
@SerialName("mac_randomization") val macRandomization: Boolean? = null,
)
@Serializable
data class Cellular(
val rat: String? = null,
val operator: String? = null,
val band: String? = null,
)
@Serializable
data class NetworkChange(
@SerialName("at_mono_ns") val atMonoNs: Long,
val kind: String, // lost | gained | link_changed
val detail: JsonObject? = null,
)
@@ -0,0 +1,90 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
enum class Verdict {
@SerialName("green") GREEN,
@SerialName("yellow") YELLOW,
@SerialName("red") RED,
@SerialName("inconclusive") INCONCLUSIVE,
}
@Serializable
data class Summary(
val overall: Verdict,
val categories: Map<String, CategorySummary>,
)
@Serializable
data class CategorySummary(
val verdict: Verdict,
@SerialName("worst_finding") val worstFinding: String? = null,
@SerialName("tests_run") val testsRun: Int,
@SerialName("tests_failed") val testsFailed: Int,
)
/**
* Deterministic verdict derivation, fixed by measurement-schema.md §7.3:
*
* - A category's verdict = the light of its worst-severity finding
* (critical|high → red, medium|low → yellow, info/none → green).
* - A category is `inconclusive` when > 50% of its tests are failed/unsupported.
* - Overall = the worst category light; `inconclusive` only when ALL categories are.
*
* The mapping test-type → category comes from [TestType.category]. Only categories that have
* findings or tests appear in the summary.
*/
object Verdicts {
private fun isInconclusiveTest(s: TestStatus) =
s == TestStatus.FAILED || s == TestStatus.UNSUPPORTED
fun derive(tests: List<Test>, findings: List<Finding>): Summary {
val testsByCat = tests.groupBy { TestType.category(it.type) }
val findingsByCat = findings.groupBy { it.category }
val categories = (testsByCat.keys + findingsByCat.keys)
val perCat = LinkedHashMap<String, CategorySummary>()
for (cat in Category.entries) {
if (cat !in categories) continue
val catTests = testsByCat[cat].orEmpty()
val catFindings = findingsByCat[cat].orEmpty()
val failed = catTests.count { isInconclusiveTest(it.status) }
val inconclusive = catTests.isNotEmpty() && failed * 2 > catTests.size
val worst = catFindings.maxByOrNull { it.severity.rank }
val verdict = when {
inconclusive -> Verdict.INCONCLUSIVE
worst == null -> Verdict.GREEN
else -> worst.severity.toLight()
}
perCat[serialName(cat)] = CategorySummary(
verdict = verdict,
worstFinding = worst?.id,
testsRun = catTests.size,
testsFailed = failed,
)
}
val overall = deriveOverall(perCat.values)
return Summary(overall = overall, categories = perCat)
}
/** Overall = worst light; inconclusive only if every category is inconclusive. */
private fun deriveOverall(cats: Collection<CategorySummary>): Verdict {
if (cats.isEmpty()) return Verdict.INCONCLUSIVE
if (cats.all { it.verdict == Verdict.INCONCLUSIVE }) return Verdict.INCONCLUSIVE
val rank = mapOf(Verdict.GREEN to 0, Verdict.YELLOW to 1, Verdict.RED to 2)
// Non-inconclusive categories decide the overall light.
return cats.filter { it.verdict != Verdict.INCONCLUSIVE }
.maxByOrNull { rank.getValue(it.verdict) }!!.verdict
}
private fun serialName(cat: Category): String = cat.name.lowercase()
}
@@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
/** The generic test envelope (measurement-schema.md §6). params must fully reproduce the test;
* evidence is append-only raw truth; metrics must be recomputable from evidence. All three are
* per-test-type JSON, so they are carried as JsonObject. */
@Serializable
data class Test(
val id: String, // UUIDv7
val type: String, // TestType registry (§6.1)
@SerialName("network_ref") val networkRef: String? = null,
@SerialName("session_ref") val sessionRef: String? = null, // null for local-only tests
val tier: Tier,
@SerialName("started_mono_ns") val startedMonoNs: Long,
@SerialName("ended_mono_ns") val endedMonoNs: Long,
val status: TestStatus,
val error: TestError? = null,
val params: JsonObject? = null,
val evidence: JsonObject? = null,
val metrics: JsonObject? = null,
)
@Serializable
enum class Tier {
@SerialName("app") APP,
@SerialName("shizuku") SHIZUKU,
@SerialName("root") ROOT,
}
@Serializable
enum class TestStatus {
@SerialName("ok") OK,
@SerialName("failed") FAILED,
@SerialName("unsupported") UNSUPPORTED,
@SerialName("skipped") SKIPPED,
@SerialName("partial") PARTIAL,
}
@Serializable
data class TestError(val code: String, val detail: String? = null)
/**
* The v1 test-type registry (§6.1). String constants (dotted, family-first) so probe code and the
* server's measurement-schema test-type registry stay aligned. [category] maps a type to one of
* the fixed §7.2 categories for verdict rollup.
*/
object TestType {
// link
const val LINK_SNAPSHOT = "link.snapshot"
const val LINK_DHCP_RENEWAL_WATCH = "link.dhcp_renewal_watch"
const val LINK_IP_MONITOR = "link.ip_monitor"
/** Who advertises IPv6 on this link (+ gateway identity). Registry addition, v1.1. */
const val LINK_RA_SOURCE = "link.ra_source"
// net — connectivity validation (reproduces Android's NetworkMonitor generate_204 checks)
const val NET_CAPTIVE_PORTAL = "net.captive_portal"
// icmp
const val ICMP_PING4 = "icmp.ping4"
const val ICMP_PING6 = "icmp.ping6"
// trace
const val TRACEROUTE_UDP4 = "traceroute.udp4"
const val TRACEROUTE_UDP6 = "traceroute.udp6"
const val TRACEROUTE_ICMP4 = "traceroute.icmp4"
const val TRACEROUTE_ICMP6 = "traceroute.icmp6"
// train
const val TRAIN_UDP_UPDOWN = "train.udp_updown"
// mtu
const val MTU_PMTUD_UP = "mtu.pmtud_up"
const val MTU_PMTUD_DOWN = "mtu.pmtud_down"
const val MTU_BLACKHOLE = "mtu.blackhole"
const val MTU_MSS_OBSERVED = "mtu.mss_observed"
const val MTU_FRAG_DELIVERY = "mtu.frag_delivery"
// nat
const val NAT_STUN_5780 = "nat.stun_5780"
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
const val NAT_HAIRPIN = "nat.hairpin"
const val NAT_CONNECT_BACK = "nat.connect_back"
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
// dns
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
const val DNS_CANARY = "dns.canary"
const val DNS_INTERCEPTION = "dns.interception"
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
const val DNS_DNSSEC = "dns.dnssec"
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
const val DNS_REBIND_FILTER = "dns.rebind_filter"
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
const val DNS_DNS64 = "dns.dns64"
const val DNS_COMPARE = "dns.compare"
// sec
const val SEC_TLS_REFERENCE = "sec.tls_reference"
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
const val SEC_HTTP_ECHO = "sec.http_echo"
const val SEC_SNI_FILTER = "sec.sni_filter"
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
const val SEC_ARP_WATCH = "sec.arp_watch"
// port
const val PORT_REACH_SWEEP = "port.reach_sweep"
const val PORT_UDP_USABILITY = "port.udp_usability"
// perf
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
const val PERF_RRC_LATENCY = "perf.rrc_latency"
// v6
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
const val V6_BROKENNESS = "v6.brokenness"
const val V6_NAT64_CLAT = "v6.nat64_clat"
// wifi
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
const val WIFI_ROAM_LOG = "wifi.roam_log"
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
// local
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
const val PEER_REACHABILITY = "peer.reachability"
const val PEER_ISOLATION = "peer.isolation"
const val PEER_MULTICAST = "peer.multicast"
const val PEER_LAN_TRAIN = "peer.lan_train"
const val PEER_LEASE_DIFF = "peer.lease_diff"
// time
const val TIME_SERVER_OFFSET = "time.server_offset"
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
fun category(type: String): Category = when (type.substringBefore('.')) {
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
"dns" -> Category.DNS
"nat" -> Category.NAT
"mtu" -> Category.MTU
"v6" -> Category.IPV6
"sec" -> Category.SECURITY
"perf" -> Category.PERFORMANCE
"local", "peer" -> Category.LOCAL
"wifi" -> Category.WIFI
else -> Category.CONNECTIVITY
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.json.Json
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SerializationTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@JTest
fun documentRoundTrips() {
val doc = MeasurementDocument(
run = Run(
id = "0198c5f2-0000-7000-8000-000000000000",
trigger = Trigger.MANUAL,
startedAt = "2026-07-31T14:03:21.114Z",
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
app = AppInfo(version = "0.1.0", build = 1),
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
tiers = Tiers(app = true, shizuku = true),
),
networks = listOf(
Network(
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
wifi = Wifi(ssid = "example", rssiDbm = -54),
),
),
tests = listOf(
Test(
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
evidence = TrainEvidence(
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
).toEvidence(),
),
),
)
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
assertEquals(doc.run.id, decoded.run.id)
assertEquals(Transport.WIFI, decoded.networks[0].transport)
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
// snake_case field names on the wire
assertTrue(encoded.contains("\"schema_version\""))
assertTrue(encoded.contains("\"mono_origin_wall\""))
assertTrue(encoded.contains("\"t_tx_ns\""))
// null preserved at train index 1
assertTrue(encoded.contains("[16500000,null]"))
}
@JTest
fun findingRequiresEvidence() {
try {
Finding(
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
)
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
} catch (e: IllegalArgumentException) {
// expected — a finding with no evidence is invalid (§7.1)
}
}
}
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
class VerdictsTest {
private fun test(type: String, status: TestStatus, id: String = type): Test =
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
Finding(
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
)
@JTest
fun categoryLightFromWorstSeverity() {
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
val findings = listOf(
finding(Category.DNS, Severity.LOW),
finding(Category.DNS, Severity.HIGH), // worst → red
finding(Category.DNS, Severity.INFO),
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun noFindingsIsGreen() {
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall)
}
@JTest
fun mediumAndLowAreYellow() {
val s = Verdicts.derive(
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
)
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
}
@JTest
fun majorityFailedIsInconclusive() {
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(2, s.categories["dns"]!!.testsFailed)
assertEquals(3, s.categories["dns"]!!.testsRun)
}
@JTest
fun exactlyHalfFailedIsNotInconclusive() {
// 1 of 2 failed → not > 50% → the finding decides.
val tests = listOf(
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
}
@JTest
fun overallIsWorstCategory() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
)
val findings = listOf(
finding(Category.DNS, Severity.MEDIUM), // yellow
finding(Category.MTU, Severity.CRITICAL), // red
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun overallInconclusiveOnlyWhenAllAre() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
)
val s = Verdicts.derive(tests, emptyList())
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
}
@JTest
fun categoryMappingCoversFamilies() {
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
}
}
+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,105 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.json.Json
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/**
* The control-plane client (probe-protocol.md §2): enrollment, profile, sessions — over
* SPKI-pinned HTTPS. Uses HttpsURLConnection (available since Android API 1, unlike
* java.net.http.HttpClient which needs API 34) with a pin-based SSLSocketFactory and hostname
* verification DISABLED: trust is the SPKI pin, never the certificate name (self-signed servers
* with no SAN are first-class). Blocking; the Android layer wraps calls in coroutines.
*
* @param controlUrl e.g. "https://fmr-1.echo-lot.app:8443"
* @param pins the `pin-sha256` value(s) from the enrollment QR (base64, no prefix)
*/
class ControlClient(private val controlUrl: String, pins: Set<String>) {
private val json = Json { ignoreUnknownKeys = true }
private val socketFactory = Pinning.sslContext(pins).socketFactory
private fun open(path: String, method: String, credential: String?): HttpsURLConnection {
val conn = URL(controlUrl.trimEnd('/') + path).openConnection() as HttpsURLConnection
conn.sslSocketFactory = socketFactory
conn.setHostnameVerifier { _, _ -> true } // pin is the trust, not the name
conn.requestMethod = method
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
credential?.let { conn.setRequestProperty("Authorization", "Bearer $it") }
return conn
}
private fun body(conn: HttpsURLConnection): String {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
return stream?.bufferedReader()?.use { it.readText() } ?: ""
}
private fun writeJson(conn: HttpsURLConnection, payload: String) {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.outputStream.use { it.write(payload.toByteArray()) }
}
// Minimal JSON string literal (the only bodies we send are one short field).
private fun jstr(s: String): String {
val sb = StringBuilder("\"")
for (c in s) when (c) {
'"' -> sb.append("\\\"")
'\\' -> sb.append("\\\\")
'\n' -> sb.append("\\n")
'\r' -> sb.append("\\r")
'\t' -> sb.append("\\t")
else -> sb.append(c)
}
return sb.append('"').toString()
}
/** Redeem a single-use enrollment token for a device credential (§2.1). */
fun enroll(token: String, name: String? = null): EnrollResponse {
val conn = open("/v1/enroll", "POST", null)
conn.setRequestProperty("Authorization", "Bearer $token")
writeJson(conn, if (name != null) """{"name":${jstr(name)}}""" else "{}")
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(EnrollResponse.serializer(), body(conn))
}
fun profile(credential: String): Profile {
val conn = open("/v1/profile", "GET", credential)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(Profile.serializer(), body(conn))
}
fun createSession(credential: String, target: String): SessionResponse {
val conn = open("/v1/sessions", "POST", credential)
writeJson(conn, """{"target":${jstr(target)}}""")
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} ${body(conn)}" }
return json.decodeFromString(SessionResponse.serializer(), body(conn))
}
/**
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
*/
fun action(credential: String, sessionId: String, bodyJson: String): String {
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
writeJson(conn, bodyJson)
val body = body(conn)
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
return body
}
fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode}" }
return body(conn)
}
fun deleteSession(credential: String, sessionId: String) {
open("/v1/sessions/$sessionId", "DELETE", credential).responseCode
}
}
@@ -0,0 +1,53 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* The protocol crypto primitives, matching the server exactly (probe-protocol.md §2.4/§3.1):
* HMAC-SHA256 for the data-plane gate, and HKDF-SHA256 for the session key
* `HKDF(ikm = device_credential, salt = key_salt, info = "echolot-v1/" + session_id)`.
* JDK-only (javax.crypto) — no third-party crypto.
*/
object Crypto {
fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray =
Mac.getInstance("HmacSHA256").run {
init(SecretKeySpec(key, "HmacSHA256"))
doFinal(data)
}
/** First 4 bytes of HMAC-SHA256 — the wire anti-abuse gate (spec §3.1). */
fun hmac32(key: ByteArray, data: ByteArray): ByteArray = hmacSha256(key, data).copyOf(4)
/**
* HKDF-SHA256 (RFC 5869) extract-then-expand. The JDK exposes no HKDF, so it is built from
* HMAC — small and standard.
*/
fun hkdfSha256(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
val prk = hmacSha256(if (salt.isEmpty()) ByteArray(32) else salt, ikm) // extract
val out = ByteArray(length)
var t = ByteArray(0)
var pos = 0
var counter = 1
while (pos < length) {
val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(prk, "HmacSHA256")) }
mac.update(t)
mac.update(info)
mac.update(counter.toByte())
t = mac.doFinal()
val n = minOf(t.size, length - pos)
t.copyInto(out, pos, 0, n)
pos += n
counter++
}
return out
}
/** Derives the 32-byte session key for a session (spec §2.4). */
fun sessionKey(credential: String, keySalt: ByteArray, sessionId: String): ByteArray =
hkdfSha256(credential.toByteArray(), keySalt, "echolot-v1/$sessionId".toByteArray(), 32)
}
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/** Control-plane JSON shapes (probe-protocol.md §2). Only fields the client uses are modeled;
* unknown fields are ignored by the lenient Json in [ControlClient]. */
@Serializable
data class EnrollResponse(
@SerialName("device_id") val deviceId: String,
val credential: String,
)
@Serializable
data class Target(
val id: String,
val ip4: String? = null,
val ip6: String? = null,
@SerialName("udp_port") val udpPort: Int = 0,
@SerialName("tcp_port") val tcpPort: Int = 0,
@SerialName("stun_port") val stunPort: Int = 0,
)
@Serializable
data class SelfTest(
@SerialName("mtu_ok") val mtuOk: Boolean? = null,
@SerialName("sysctl_ok") val sysctlOk: Boolean? = null,
)
@Serializable
data class Profile(
@SerialName("profile_version") val profileVersion: Int = 0,
val name: String = "",
@SerialName("server_version") val serverVersion: String = "",
val capabilities: List<String> = emptyList(),
val targets: List<Target> = emptyList(),
@SerialName("canary_zone") val canaryZone: String = "",
@SerialName("server_selftest") val serverSelftest: SelfTest? = null,
val pins: List<String> = emptyList(),
) {
fun supports(capability: String) = capability in capabilities
}
@Serializable
data class SessionResponse(
@SerialName("session_id") val sessionId: String,
@SerialName("key_salt") val keySalt: String, // base64
val epoch: String,
@SerialName("expires_s") val expiresS: Int,
)
/** Observations bundle (§6). Kept as raw JSON where the shape is still evolving server-side. */
@Serializable
data class Observations(
val udp: JsonElement? = null,
val tcp: JsonElement? = null,
@SerialName("connect_back") val connectBack: JsonElement? = null,
@SerialName("dns_canary") val dnsCanary: JsonElement? = null,
)
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.security.MessageDigest
import java.security.cert.X509Certificate
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
/**
* SPKI-pinned trust (probe-protocol.md §1): the client trusts the server ONLY against the
* `pin-sha256` from enrollment — CA validation is not required and self-signed is first-class.
* The pin is base64(SHA-256(SubjectPublicKeyInfo)), RFC 7469.
*/
object Pinning {
fun spkiPin(cert: X509Certificate): String {
val spki = cert.publicKey.encoded // DER SubjectPublicKeyInfo
val digest = MessageDigest.getInstance("SHA-256").digest(spki)
return java.util.Base64.getEncoder().encodeToString(digest)
}
/** An SSLContext that accepts a chain iff its leaf SPKI matches one of the expected pins. */
fun sslContext(expectedPins: Set<String>): SSLContext {
val tm = object : X509TrustManager {
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
val leaf = chain.firstOrNull() ?: throw java.security.cert.CertificateException("empty chain")
val pin = spkiPin(leaf)
if (pin !in expectedPins) {
throw java.security.cert.CertificateException("SPKI pin mismatch: got $pin")
}
}
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
return SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
}
}
@@ -0,0 +1,111 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.util.Base64
/**
* A data-plane session (probe-protocol.md §3): derives the session key, then sends signed ELT1
* packets to the server's UDP endpoint and reads back verified responses. One session ↔ one
* server target. Blocking; the caller owns threading.
*/
class ProbeSession(
private val credential: String,
private val session: SessionResponse,
private val serverHost: String,
private val serverUdpPort: Int,
) : AutoCloseable {
private val key: ByteArray =
Crypto.sessionKey(credential, Base64.getDecoder().decode(session.keySalt), session.sessionId)
private val prefix: ByteArray = Wire.wirePrefix(session.sessionId)
private val epochNanos = System.nanoTime()
private val socket = DatagramSocket().apply { soTimeout = 3000 }
private val server = InetSocketAddress(serverHost, serverUdpPort)
private var seq = 0
private fun nowNs() = System.nanoTime() - epochNanos
/**
* One ECHO round trip. Returns RTT in ms and the server's observation, or null on loss.
*
* The response is capped at the request size (§3.4 anti-amplification) and the observation
* block is 40 bytes, so the request must be at least header+40 = 72 bytes for the full
* observation to fit — hence the ≥40 default padding. Smaller requests still measure RTT.
*/
fun echo(paddingBytes: Int = 40): EchoResult? {
val t0 = System.nanoTime()
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, ++seq, nowNs(), key, ByteArray(paddingBytes))
socket.send(DatagramPacket(pkt, pkt.size, server))
val resp = receive(Wire.TYPE_ECHO_RESP) ?: return null
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
return EchoResult(rttMs, Observation.parse(resp.payload))
}
/** One MTU probe of [totalSize] bytes (DF is set by the OS on the socket where supported).
* Returns the size the server acknowledged receiving, or null if the probe was lost. */
fun mtuProbe(totalSize: Int): Int? {
val payloadLen = (totalSize - Wire.HEADER_SIZE).coerceAtLeast(0)
val pkt = Wire.build(Wire.TYPE_MTU_PROBE, prefix, ++seq, nowNs(), key, ByteArray(payloadLen))
socket.send(DatagramPacket(pkt, pkt.size, server))
val resp = receive(Wire.TYPE_MTU_ACK) ?: return null
if (resp.payload.size < 4) return null
return ((resp.payload[0].toInt() and 0xFF) shl 24) or
((resp.payload[1].toInt() and 0xFF) shl 16) or
((resp.payload[2].toInt() and 0xFF) shl 8) or
(resp.payload[3].toInt() and 0xFF)
}
/**
* Collects packets the SERVER sends under a grant (downtrain / big_send) for [windowMs].
* These arrive unsolicited after a control-plane action, so this just drains the socket and
* keeps every HMAC-verified packet — anything that fails verification is not ours and is
* silently ignored (an injected packet must not be able to fake a measurement).
*/
fun collectGranted(windowMs: Long): List<Received> {
val out = ArrayList<Received>()
val deadline = System.nanoTime() + windowMs * 1_000_000
val buf = ByteArray(9200)
val prevTimeout = socket.soTimeout
try {
while (System.nanoTime() < deadline) {
val remainMs = ((deadline - System.nanoTime()) / 1_000_000).toInt()
if (remainMs <= 0) break
socket.soTimeout = remainMs.coerceAtMost(2000)
val dp = DatagramPacket(buf, buf.size)
try {
socket.receive(dp)
} catch (e: java.net.SocketTimeoutException) {
continue
}
val pkt = Wire.parseVerified(buf, dp.length, key) ?: continue
out.add(Received(pkt.type, pkt.seq, dp.length, (System.nanoTime() - epochNanos)))
}
} finally {
socket.soTimeout = prevTimeout
}
return out
}
/** One packet received from the server, with the wire size actually delivered. */
data class Received(val type: Int, val seq: Int, val sizeBytes: Int, val tRxNs: Long)
private fun receive(wantType: Int): Wire.Packet? {
val buf = ByteArray(2048)
return try {
val dp = DatagramPacket(buf, buf.size)
socket.receive(dp)
Wire.parseVerified(buf, dp.length, key)?.takeIf { it.type == wantType }
} catch (e: java.net.SocketTimeoutException) {
null
}
}
override fun close() = socket.close()
data class EchoResult(val rttMs: Double, val observation: Observation?)
}
@@ -0,0 +1,118 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* The binary UDP probe protocol wire format (probe-protocol.md §3.1): a fixed 32-byte header
* plus payload, HMAC-gated. Mirrors the Go server's dataplane package byte-for-byte.
*
* ```
* 0 4 magic "ELT1" 8 8 session_prefix (first 8 bytes of session id)
* 4 1 type 16 4 seq
* 5 1 flags 20 8 t_ns (sender clock, ns since session epoch)
* 6 2 payload_len 28 4 hmac32(session_key, header[0..28] || payload)
* ```
*/
object Wire {
const val HEADER_SIZE = 32
val MAGIC = byteArrayOf('E'.code.toByte(), 'L'.code.toByte(), 'T'.code.toByte(), '1'.code.toByte())
const val TYPE_ECHO_REQ: Int = 0x01
const val TYPE_ECHO_RESP: Int = 0x02
const val TYPE_TIMESYNC_REQ: Int = 0x07
const val TYPE_TIMESYNC_RSP: Int = 0x08
const val TYPE_MTU_PROBE: Int = 0x09
const val TYPE_MTU_ACK: Int = 0x0A
const val TYPE_DELAYED_ECHO: Int = 0x0B
/** Server->client under an asymmetric grant (spec §3.4/§5). */
const val TYPE_DOWNTRAIN_DATA: Int = 0x06
const val TYPE_BIG_SEND: Int = 0x0C
/** The 8-byte on-the-wire prefix = first 16 hex chars of the session id, decoded. */
fun wirePrefix(sessionId: String): ByteArray {
require(sessionId.length >= 16) { "session id too short" }
val p = ByteArray(8)
for (i in 0 until 8) {
p[i] = ((hex(sessionId[i * 2]) shl 4) or hex(sessionId[i * 2 + 1])).toByte()
}
return p
}
private fun hex(c: Char): Int = when (c) {
in '0'..'9' -> c - '0'
in 'a'..'f' -> c - 'a' + 10
in 'A'..'F' -> c - 'A' + 10
else -> 0
}
/** Builds a signed packet ready to send. */
fun build(
type: Int, sessionPrefix: ByteArray, seq: Int, tNs: Long, key: ByteArray,
payload: ByteArray = ByteArray(0),
): ByteArray {
val buf = ByteBuffer.allocate(HEADER_SIZE + payload.size).order(ByteOrder.BIG_ENDIAN)
buf.put(MAGIC)
buf.put(type.toByte())
buf.put(0) // flags
buf.putShort(payload.size.toShort())
buf.put(sessionPrefix, 0, 8)
buf.putInt(seq)
buf.putLong(tNs)
buf.position(28) // leave hmac slot; fill after
buf.putInt(0)
buf.put(payload)
val bytes = buf.array()
// HMAC over header[0..28] || payload (the hmac slot itself excluded).
val mac = Crypto.hmacSha256(key, concat(bytes, 0, 28, bytes, HEADER_SIZE, payload.size))
mac.copyInto(bytes, 28, 0, 4)
return bytes
}
/** A parsed, HMAC-verified inbound packet. */
data class Packet(val type: Int, val seq: Int, val tNs: Long, val payload: ByteArray)
/** Parses and verifies an inbound datagram; null if malformed or the HMAC fails. */
fun parseVerified(data: ByteArray, len: Int, key: ByteArray): Packet? {
if (len < HEADER_SIZE) return null
for (i in MAGIC.indices) if (data[i] != MAGIC[i]) return null
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
val type = bb.get(4).toInt() and 0xFF
val payloadLen = bb.getShort(6).toInt() and 0xFFFF
if (HEADER_SIZE + payloadLen > len) return null
val expect = Crypto.hmacSha256(key, concat(data, 0, 28, data, HEADER_SIZE, payloadLen))
for (i in 0 until 4) if (expect[i] != data[28 + i]) return null
val seq = bb.getInt(16)
val tNs = bb.getLong(20)
val payload = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + payloadLen)
return Packet(type, seq, tNs, payload)
}
private fun concat(a: ByteArray, aOff: Int, aLen: Int, b: ByteArray, bOff: Int, bLen: Int): ByteArray {
val out = ByteArray(aLen + bLen)
a.copyInto(out, 0, aOff, aOff + aLen)
b.copyInto(out, aLen, bOff, bOff + bLen)
return out
}
}
/** Server observation block appended to ECHO_RESP (spec §3.3), fixed 40 bytes. */
data class Observation(
val tRxNs: Long, val tTxNs: Long, val observedPort: Int, val receivedSize: Int,
) {
companion object {
fun parse(payload: ByteArray): Observation? {
if (payload.size < 40) return null
val bb = ByteBuffer.wrap(payload).order(ByteOrder.BIG_ENDIAN)
return Observation(
tRxNs = bb.getLong(0),
tTxNs = bb.getLong(8),
observedPort = bb.getShort(32).toInt() and 0xFFFF,
receivedSize = bb.getInt(36),
)
}
}
}
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class CryptoWireTest {
@Test
fun hkdfMatchesRfc5869Vector() {
// RFC 5869 Appendix A.1 (SHA-256).
val ikm = ByteArray(22) { 0x0b }
val salt = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
val info = byteArrayOf(
0xf0.toByte(), 0xf1.toByte(), 0xf2.toByte(), 0xf3.toByte(), 0xf4.toByte(),
0xf5.toByte(), 0xf6.toByte(), 0xf7.toByte(), 0xf8.toByte(), 0xf9.toByte(),
)
val okm = Crypto.hkdfSha256(ikm, salt, info, 42)
val expect = "3cb25f25faacd57a90434f64d0362f2a" +
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf" +
"34007208d5b887185865"
assertEquals(expect, okm.joinToString("") { "%02x".format(it) })
}
@Test
fun wirePrefixDecodesHex() {
val prefix = Wire.wirePrefix("805a43f8395ae08ace7a14803766cb11")
assertEquals("805a43f8395ae08a", prefix.joinToString("") { "%02x".format(it) })
}
@Test
fun buildThenParseRoundTripsAndVerifies() {
val key = ByteArray(32) { it.toByte() }
val prefix = ByteArray(8) { (it + 1).toByte() }
val payload = "hello-echolot".toByteArray()
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, prefix, 7, 123_456L, key, payload)
assertEquals(Wire.HEADER_SIZE + payload.size, pkt.size)
val parsed = Wire.parseVerified(pkt, pkt.size, key)
assertNotNull(parsed)
assertEquals(Wire.TYPE_ECHO_REQ, parsed.type)
assertEquals(7, parsed.seq)
assertEquals(123_456L, parsed.tNs)
assertEquals("hello-echolot", String(parsed.payload))
}
@Test
fun tamperedHmacIsRejected() {
val key = ByteArray(32) { it.toByte() }
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, key, ByteArray(4))
pkt[pkt.size - 1] = (pkt[pkt.size - 1].toInt() xor 0xFF).toByte() // flip a payload byte
assertNull(Wire.parseVerified(pkt, pkt.size, key))
}
@Test
fun wrongKeyIsRejected() {
val pkt = Wire.build(Wire.TYPE_ECHO_REQ, ByteArray(8), 1, 0, ByteArray(32) { 1 }, ByteArray(0))
assertNull(Wire.parseVerified(pkt, pkt.size, ByteArray(32) { 2 }))
}
@Test
fun observationParses() {
// 40-byte block: t_rx, t_tx, 16-byte addr, port, ttl/dscp, size.
val b = ByteArray(40)
b[33] = 0x1F // port low byte = 8191... set port bytes 32..33
b[32] = 0x00
val obs = Observation.parse(b)
assertNotNull(obs)
assertTrue(obs.observedPort in 0..65535)
}
}
@@ -0,0 +1,66 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* End-to-end test of the Kotlin client against a REAL running server. It self-skips unless the
* environment provides a live target, so it never breaks CI (no network / no server):
*
* ECHOLOT_LIVE_URL = https://fmr-1.echo-lot.app:8443
* ECHOLOT_LIVE_PIN = <base64 pin-sha256>
* ECHOLOT_LIVE_CRED = <device credential from an enrollment>
* ECHOLOT_LIVE_UDP = fmr-1.echo-lot.app:8442
* ECHOLOT_LIVE_TARGET = fmr (profile target id)
*
* The harness (test-fmr.sh) mints a token over SSH, enrolls via the public control plane, and
* exports these — proving the client talks to the deployed server over the wire.
*/
class LiveServerTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun fullFlowAgainstLiveServer() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveServerTest skipped (no ECHOLOT_LIVE_* env)")
return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("profile: name=${profile.name} v=${profile.serverVersion} caps=${profile.capabilities}")
assertTrue(profile.supports("udp-probe"), "server must offer udp-probe")
val session = control.createSession(cred, target)
println("session: ${session.sessionId} expires=${session.expiresS}s")
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// ECHO: verified response + observation with our observed port.
val echo = ps.echo(paddingBytes = 64) // ≥40 so the observation block fits (§3.4)
assertNotNull(echo, "no verified ECHO_RESP from live server")
println("echo rtt=${"%.1f".format(echo.rttMs)}ms observedPort=${echo.observation?.observedPort} size=${echo.observation?.receivedSize}")
assertNotNull(echo.observation, "ECHO_RESP missing observation block")
// MTU probe: server acks the size it received.
val acked = ps.mtuProbe(1400)
assertNotNull(acked, "no MTU_ACK from live server")
println("mtu probe 1400 -> server received $acked bytes")
assertTrue(acked!! in 1300..1500, "acked size implausible: $acked")
}
val obs = control.observations(cred, session.sessionId)
println("observations bytes: ${obs.length}")
assertTrue(obs.contains("packets_seen"), "observations should report packets_seen")
control.deleteSession(cred, session.sessionId)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+35
View File
@@ -0,0 +1,35 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
// AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
alias(libs.plugins.android.library)
}
// The Shizuku (shell-tier) executor + probe. Ported from the validated
// echolot-prober with the build-4 dual-path fix: bind the UserService when it
// works (OnePlus), fall back to the legacy newProcess API when it doesn't
// (Lenovo). Emits a core-measurement Test.
android {
namespace = "app.echo_lot.shizuku"
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
aidl = true // IUserService
}
}
dependencies {
implementation(project(":core-measurement"))
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.serialization.json)
}
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
SPDX-License-Identifier: GPL-3.0-or-later -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="moe.shizuku.manager.permission.API_V23" />
<!-- Android 11+ package visibility: needed to tell "Shizuku installed but stopped"
(worth a reminder) apart from "not installed" (say nothing). -->
<queries>
<package android:name="moe.shizuku.privileged.api" />
</queries>
<application>
<!-- Shizuku binder provider (merged into the host app). -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
</application>
</manifest>
@@ -0,0 +1,8 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku;
interface IUserService {
void destroy();
String exec(String command, int timeoutMs);
}
@@ -0,0 +1,117 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.shizuku
import android.content.Context
import android.content.pm.PackageManager
import rikka.shizuku.Shizuku
/**
* Tells the UI whether the shell tier is usable **before** a run starts, so the app can say
* "Shizuku isn't running, shell-tier tests will be skipped" instead of silently producing an
* UNSUPPORTED result minutes later.
*
* Detection is reliable but *asynchronous*: `Shizuku.pingBinder()` answers truthfully only once
* ShizukuProvider has delivered the binder to this process, which happens shortly after start.
* [observe] therefore reports the current state immediately and again whenever the binder
* arrives or dies — a poll at t=0 alone would show a false "not running".
*/
object ShizukuAvailability {
const val SHIZUKU_PACKAGE = "moe.shizuku.privileged.api"
enum class State {
/** Running and this app may use it — shell tier will execute. */
READY,
/** Running, but the user hasn't granted this app permission yet (we can ask). */
NEEDS_PERMISSION,
/** Shizuku IS installed but its service isn't started — worth reminding this user. */
INSTALLED_NOT_RUNNING,
/** Shizuku isn't installed at all — say nothing; this user doesn't use the shell tier. */
NOT_INSTALLED,
}
/** Is the Shizuku manager installed? Needs the <queries> entry on Android 11+. */
fun isInstalled(context: Context): Boolean = runCatching {
context.packageManager.getPackageInfo(SHIZUKU_PACKAGE, 0); true
}.getOrDefault(false)
fun current(context: Context): State = when {
runCatching { Shizuku.pingBinder() }.getOrDefault(false) ->
if (runCatching { Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED }
.getOrDefault(false)
) State.READY else State.NEEDS_PERMISSION
isInstalled(context) -> State.INSTALLED_NOT_RUNNING
else -> State.NOT_INSTALLED
}
/**
* UI one-liner, or null when nothing should be said. Users without Shizuku get no nag; users
* who have it installed but stopped get the reminder that makes the difference between a
* full run and a silently skipped shell tier.
*/
fun describe(s: State): String? = when (s) {
State.READY -> "Shizuku ready — shell-tier tests will run"
State.NEEDS_PERMISSION -> "Shizuku is running but not authorised — it will ask on first use"
State.INSTALLED_NOT_RUNNING -> "Shizuku is installed but not running — start it to include shell-tier tests"
State.NOT_INSTALLED -> null
}
/**
* Intent that opens the Shizuku manager.
*
* Only `MainActivity` is reachable: verified against Shizuku 13.6's own manifest, the
* wireless-debugging entry points — `moe.shizuku.manager.adb.AdbPairingTutorialActivity`,
* `moe.shizuku.manager.adb.AdbPairingService` and `moe.shizuku.manager.starter.StarterActivity`
* — declare no intent filters, so they are NOT exported and a third-party app cannot launch
* them (MainActivity itself only answers MAIN/LAUNCHER, no deep link). Landing on the main
* screen and telling the user which button to press is therefore the best available handoff.
*/
fun launchIntent(context: Context): android.content.Intent? =
context.packageManager.getLaunchIntentForPackage(SHIZUKU_PACKAGE)
?.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
/** Asks Shizuku for permission (only meaningful while it is running). */
fun requestPermission(requestCode: Int = 0xE1) {
runCatching { Shizuku.requestPermission(requestCode) }
}
/**
* What tapping the notice does. For the stopped case the label names the exact steps inside
* Shizuku, because we can only hand the user to its main screen (see [launchIntent]).
*/
fun actionHint(s: State): String? = when (s) {
State.INSTALLED_NOT_RUNNING ->
"Tap to open Shizuku → \"Pairing\" to pair, then \"Start\" (wireless debugging)"
State.NEEDS_PERMISSION -> "Tap to grant permission"
else -> null
}
/**
* Developer-options screen, where Wireless debugging is enabled — the prerequisite Shizuku's
* wireless start depends on. This action IS public and exported, unlike Shizuku's own
* pairing screen.
*/
fun developerOptionsIntent(): android.content.Intent =
android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DEVELOPMENT_SETTINGS)
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
/**
* Reports the state now and on every binder transition. Returns a function that removes the
* listeners again (call it from onCleared).
*/
fun observe(context: Context, onChange: (State) -> Unit): () -> Unit {
val app = context.applicationContext
val received = Shizuku.OnBinderReceivedListener { onChange(current(app)) }
val dead = Shizuku.OnBinderDeadListener { onChange(current(app)) }
// "Sticky" fires immediately if the binder already arrived before we registered.
runCatching { Shizuku.addBinderReceivedListenerSticky(received) }
runCatching { Shizuku.addBinderDeadListener(dead) }
onChange(current(app))
return {
runCatching { Shizuku.removeBinderReceivedListener(received) }
runCatching { Shizuku.removeBinderDeadListener(dead) }
}
}
}

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