Compare commits

...
Author SHA1 Message Date
mrambossekandClaude Fable 5 a7dccf7da2 frag_send: crafted IP fragments, so ordering can be tested and not just delivery
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 32s
server-release / release (push) Successful in 32s
Letting the kernel fragment an oversized datagram answers one question — do
fragments get through. It cannot answer the more interesting one, because the
kernel always emits them in order, first one first.

The classic middlebox fault is exactly about that ordering. Only the first
fragment carries the UDP header, and therefore the ports; a stateful firewall
or NAT that has not seen it has no flow to match the rest against, and many
drop them. That is invisible to any in-order test and shows up in the field as
"large DNS answers fail on this network" or "the tunnel breaks when the MTU
drops" — it works until the network reorders, then fails intermittently, which
is the hardest kind of fault to chase.

So the server now builds the fragments itself (raw socket, IP_HDRINCL) and
controls their order: in_order as a baseline, reversed, and first-fragment-last.
The datagram is assembled and signed whole before being cut up, so what the
client reassembles is indistinguishable from an ordinary packet — otherwise it
would be measuring our sender rather than the path.

Two details that would silently produce wrong answers:
  - The UDP checksum is computed rather than left zero. A zero-checksum datagram
    is dropped by some middleboxes, and that drop would be recorded as a
    fragmentation failure, which is the wrong conclusion entirely.
  - Fragment offsets are in 8-byte units, so non-final fragments are rounded to
    a multiple of 8. A 100-byte fragment is not an error, it is a datagram no
    host will ever reassemble.

frag-send is advertised only when a raw socket can actually be opened — checked
by opening one, since a permission model has more ways to say no than a
capability bit has to say yes.

Fragment header arithmetic is unit-tested (reassembly coverage, MF flags, shared
IP ID, 8-byte offsets, checksum verification), cross-compiled and run on Linux
since the code is build-tagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:45:09 +02:00
mrambossekandClaude Fable 5 4ffa6e4ae2 engine: split packet loss by direction using the server's observations
"3 % loss" sends an engineer looking in both directions at once. The server
records every packet it received per sequence number, so the two cases are
distinguishable: sent-but-never-seen is upstream loss, seen-but-no-reply is
downstream. The findings say which, and say what is not implicated.

Downstream loss is measured against what reached the server, not against what
was sent — the other denominator counts every upstream loss twice and
overstates the return path.

Per-direction jitter comes out of the same records without needing synchronised
clocks: (server_rx - client_tx) carries a constant unknown offset, and
differencing successive samples cancels it, so RFC 3393 variation is honestly
attributable to a direction even though absolute latency is not.

Correlation is by wire sequence number, not loop index — the counter is shared
with every packet type on the session. ProbeSession exposes it even for a lost
probe, since that is precisely the packet whose direction is in question.

Live against fmr: 0.08 ms upstream jitter vs 0.85 ms downstream, an asymmetry a
round-trip test cannot see.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 13:09:59 +02:00
mrambossekandClaude Fable 5 3e7e3b8d33 scripts: one command to mint an enrollment link, QR included
Scanning beats pasting a 200-character string onto a phone, and with a device
attached the deep link can be delivered by adb with no typing at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:12:28 +02:00
mrambossekandClaude Fable 5 199807a8c9 docs: enrollment link encoding rules in the spec, session log in build-status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:11:37 +02:00
mrambossekandClaude Fable 5 5291bdd045 enrollment: actually emit enroll_uri from the admin endpoint
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 31s
The previous commit's edit to the admin handler silently did not apply, so the
endpoint still returned just the token. Caught by deploying and looking at the
response rather than by trusting the build to have picked it up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:08:11 +02:00
mrambossekandClaude Fable 5 fe3658e009 chore: ignore the Kotlin compiler's .kotlin scratch directory
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:47 +02:00
mrambossekandClaude Fable 5 ad85f3bfcd enrollment: the server mints the §2.1 bootstrap link, the app consumes it
server-release / image (push) Successful in 15s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
POST /admin/enroll-tokens now returns the whole link, not just the token:

  echolot://enroll?v=1&u=<control URL>&p=pin-sha256:<b64>&t=<token>

The server is the only party that knows all three parts at once, and the part
an operator gets wrong by hand is the base64 pin — which does not fail loudly,
it just never matches, surfacing days later as an inscrutable TLS error. The
app takes the link from a paste or from an echolot:// deep link (QR scan), and
writes URL, pin and credential together or not at all.

One trap the tests pin: an unencoded "+" in a query string decodes to a space,
so a hand-assembled link arrives with a pin wrong by one character. Base64 has
no spaces, so they are restored — unambiguous, and it cannot damage a correctly
encoded pin.

Also fixes a spec divergence: §2.1 names the field device_credential and the
first implementation shipped "credential". Both are sent now and the client
prefers the spec's; the alias goes once nothing reads it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 12:06:22 +02:00
mrambossekandClaude Fable 5 8166611af1 docs: record the compatibility-window work in build-status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:43:02 +02:00
mrambossekandClaude Fable 5 33a6acb0bf compat: stop mangling refusal messages with HTML escapes
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 31s
The server's 426 body reached the user as "needs \u003e= 0.2.0, \u003c 1.0.0":
Go escapes <, > and & by default for JSON destined for a page, which this is
not. Disabled at the encoder. The client now parses the error field rather than
pattern-matching it, so it survives whatever a future encoder decides to escape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:41:00 +02:00
mrambossekandClaude Fable 5 9d6572bc33 compat: fix the too-new message's grammar, add a live gate test
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 29s
server-release / release (push) Successful in 30s
The generated refusal read "point at a app within range". Also adds
LiveCompatTest, which checks the half a unit test cannot reach: that two
independently-built artifacts agree on the window, that the profile stays
readable for a version the server refuses, and that both bounds are enforced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:38:45 +02:00
mrambossekandClaude Fable 5 0c5b021b63 compat: SemVer version windows between app and server
server-release / image (push) Successful in 14s
server-test / test (push) Successful in 30s
server-release / release (push) Successful in 30s
Both sides now declare what they will talk to, and enforce it. Two axes kept
deliberately separate, because conflating them is the trap:

  protocol_version  — CAN these builds talk. The correctness axis. Below 1.0.0
                      the minor is the breaking axis, per SemVer §4.
  release window    — MAY they, per policy. [min, max), advertised in the
                      profile, overridable by the operator.

The server refuses out-of-window apps with 426 and a body naming both versions
and the accepted range; the app checks the profile in both directions before a
run rather than discovering mid-measurement that it will be refused.

Three rules that shape the rest:

  - GET /v1/profile is never gated. It is where a refused client learns which
    version it needs; gating it leaves the user with a network error instead of
    an answer, which is precisely the confusion this exists to remove.
  - An unparseable or absent version is "unknown", and is allowed. Development
    builds report "dev", and a client too old to send the header cannot be
    identified anyway.
  - Bounds sit at breaking boundaries, not at releases, so shipping a patch
    never requires editing a range. The app's server minimum is 0.4.2 for a
    stated reason: earlier multi-homed servers mis-addressed granted sends and
    the client measured 100% downstream loss that never happened.

The app's versionCode is now derived from its SemVer instead of being a second
number someone has to remember to bump.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 11:36:34 +02:00
mrambossekandClaude Fable 5 277e33da75 chore: drop core-measurement/bin from the index too
deploy-site / deploy (push) Failing after 1m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 10:54:48 +02:00
mrambossekandClaude Fable 5 14e5fad1b2 engine: downstream MTU and downstream train in the measurement document
Three facts the client cannot produce alone, kept deliberately separate:
mtu.pmtud_down (largest datagram that arrives unfragmented — meaningful only
because the server sets DF), mtu.frag_delivery (whether larger ones arrive once
fragmentation is allowed), and train.udp_downstream (loss, reordering and
arrival spacing in the download direction, which a round trip cannot separate
from upstream loss).

ServerMeasurement now runs them on the same ProbeSession as the echo train. It
had to: a fresh session restarts client-side sequence numbers and the server's
anti-replay window discards the lot, so the re-primed source is never recorded
and every granted send goes to a socket that has already closed. That produced
four confidently-wrong FAILED tests and a RED verdict on a healthy network.

Live against fmr: path MTU 1500, fragments to 4000, 100/100 downstream, GREEN.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:18:50 +02:00
177 changed files with 15988 additions and 177 deletions
+6
View File
@@ -40,3 +40,9 @@ 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/
# Kotlin compiler scratch/error logs
echolot-app/.kotlin/
+80 -4
View File
@@ -32,10 +32,25 @@ Keep prober result IDs aligned with the measurement-schema test-type registry.
## Versioning
Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental work; reserve **minor** bumps
for real milestones. Don't burn through minor versions. Tags are namespaced: `server-v*` for the
Go server, `v*` for the app. Pushing a `server-v*` tag runs CI → binaries + Gitea release +
registry image; the server on fmr can `--self-update` from those releases.
Both artifacts are **SemVer**. Prefer **patch** bumps (`server-v0.3.1`) for additive/incremental
work; reserve **minor** bumps for real milestones. Don't burn through minor versions. Tags are
namespaced: `server-v*` for the Go server, `v*` for the app. Pushing a `server-v*` tag runs CI →
binaries + Gitea release + registry image; the server on fmr can `--self-update` from those
releases.
The app's version lives once, as `appVersionName` in `app/build.gradle.kts`; **`versionCode` is
derived from it** (`major*1e6 + minor*1e4 + patch*10`). Never set it by hand — a second number a
human has to remember to bump eventually disagrees with the first.
**Versions are load-bearing** (probe-protocol.md §8): the server refuses apps outside its window
with `426`, and the app refuses servers outside its own. Two axes, kept separate:
- `protocol_version`*can* they talk. The correctness axis; below 1.0.0 the **minor** is the
breaking axis.
- release-version window — *may* they, per policy. `[min, max)`, bounds at breaking boundaries so
a patch never strands a fleet. Client bounds: `Compat.kt`. Server: `ECHOLOT_MIN/MAX_APP_VERSION`.
Raise a minimum only when older peers are actively harmful, and say why in the constant's comment.
`GET /v1/profile` must stay ungated — it is how a refused client learns what it needs.
## Layout
@@ -52,6 +67,29 @@ echolot-prober/ the capability prober (self-contained Gradle buil
ui/ProberScreen.kt result cards colored by verdict
```
## Client modules (echolot-app/)
Pure Kotlin/JVM where possible, so the interesting logic is unit-testable without a device and can
be exercised against the live server from the PC:
- `core-protocol` — control plane (pinned TLS) + ELT1 UDP data plane. **One `ProbeSession` per
server session, for its whole lifetime**: a second one restarts sequence numbers, the server's
anti-replay window discards every packet, and granted sends then target the closed socket.
- `core-measurement` — the schema types. `core-engine` — composes probes into documents.
- `core-privacy` — the §8 anonymizer (`full` / `balanced` / `strict`). Field classification lives
in one table (`Classification.kt`); keep it there rather than annotating models.
- `core-archive` — on-device run storage + retention. `enabled` is separate from the three
ceilings: all-zeros means "no limits", not "keep nothing".
**The local archive keeps the unredacted document; anonymization happens per upload, on the way
out.** Never redact what is stored locally.
### Live testing without a device
`echolot-app/scripts/test-fmr.sh [gradle-task] [test-filter]` mints an enrollment token over SSH,
enrolls, computes the SPKI pin from the served cert and runs a `Live*Test` against fmr. This covers
the whole server-facing vertical (granted sends, downstream MTU, uploads) with no phone involved —
use it before asking the user to test on hardware.
## Conventions
- Every probe returns a `ProbeResult` — never throws to the caller (MainActivity wraps anyway).
@@ -83,6 +121,36 @@ 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).
- **Empty-jar race with the IDE.** VSCodium's Java/Kotlin extension runs its own Gradle daemon on
the same project; when it overlaps a CLI build, a module's `build/libs/*.jar` can end up
containing only a manifest, and Gradle then considers `jar` up-to-date. Dependent modules fail
with "Unresolved reference" on symbols that plainly exist. Fix: `rm -f <module>/build/libs/*.jar`
and re-run the `jar` task. Suspect this whenever a reference resolves in one module but not in
its consumer.
- 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`.
@@ -94,3 +162,11 @@ First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.
are SUPPORTED on both known devices via `Os.recvmsg` + `StructMsghdr` reflection.
3. Fold the confirmed capabilities + Shizuku dump-format samples back into the production
`core-probe` / `core-shizuku` modules.
## Enrolling a device with a server
`echolot-app/scripts/enroll-link.sh [note]` mints a §2.1 bootstrap link on fmr over SSH and prints
it (plus a QR if `qrencode` is installed, plus the `adb shell am start -a …VIEW -d '<uri>'` command
when a device is attached). The link carries a single-use token — treat it as a secret until spent.
Never hand-assemble one: the base64 pin needs percent-encoding, and a pin wrong by one character
fails as an inscrutable TLS error rather than as a bad pin.
+486
View File
@@ -242,3 +242,489 @@ 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.
### Downstream measurements: asymmetric grants, DF-mode big_send (server-v0.4.0 … v0.4.2, 2026-08-01)
The client can measure a round trip and the largest packet it can *send*. It cannot measure the
largest packet it can *receive*, or downstream-only loss — those need the server to push, which is
exactly what §3.4 gates behind an asymmetric grant. Implemented and verified live from the PC:
- **`session.Grant`** — created per action, bound at creation to the session's *observed*
data-plane source (no grant without a verified destination), clamped to server limits, with a
byte budget, an average-rate ceiling and an expiry. Unit-tested for each of those refusals.
- **`downtrain`** — N packets of size S every I µs; the client derives downstream loss,
reordering and inter-arrival spacing.
- **`big_send`** — one datagram per requested size. **DF is on by default**, so the largest size
that arrives *is* the downstream path MTU. Without DF the kernel fragments and the result only
says whether fragments get through — a different fact, and the reason the schema has both
`mtu.pmtud_down` and `mtu.frag_delivery`. Sizes above the server's own egress MTU (from the
startup self-test) are refused up front and reported as `max_df_bytes`, so an absence caused by
our kernel is never read as a limit of the client's path.
Live from the PC against fmr: downstream path MTU **1500** (1472 payload, DF), fragmented delivery
up to **4000**, downstream train **100/100, 0 % loss, 0 reordered**, inter-arrival 3.3 ms for a
3000 µs send interval.
#### Two bugs this shook out, both invisible in a single-homed lab
1. **Granted sends went out from the wrong local address** (fixed in server-v0.4.2). fmr binds two
IPv4 addresses; `connFor` returned whichever socket of the right family came first in the bind
list. A train for a session established on `.150` left from `.151` and every packet was dropped
by the client's NAT, which has no mapping for that pair. tcpdump showed all 50 leaving, the
client saw none — reported as *100 % downstream loss*, a confident measurement of something
that never happened. Sessions now record which of our own bound addresses received their
traffic and granted sends go back through that socket; `connfor_test.go` pins both that and the
family fallback.
2. **A second `ProbeSession` on one server session is silently dead.** Sequence numbers restart at
zero client-side while the server's anti-replay window keeps counting, so every packet is
discarded as a replay — and because the server then never records the new source, the grant
still targets the closed socket. `ServerMeasurement` now uses one ProbeSession for the whole
run; `ProbeSession`'s doc comment states the constraint.
### Run archive, anonymizer and uploads (2026-08-01)
Three pieces, deliberately separate:
- **`core-archive`** — one JSON file per run plus an index entry, in a plain directory the user can
inspect or delete with a file manager. Retention (max runs / max age / max total bytes) is
enforced on every save rather than by a sweeper. `enabled` is a separate flag from the three
ceilings because "no limits" and "keep nothing" are opposite intentions; collapsing them onto
all-zeros is how a user who turns the caps off ends up with an empty history. 13 tests.
- **`core-privacy`** — the schema §8 anonymizer, three levels. `full` (your own server) changes
nothing; `balanced` pseudonymizes SSIDs/hostnames, keeps the OUI half of a MAC and the /16 of a
public IP, keeps RFC1918 verbatim (it describes topology, not a person), and *drops* neighbour
inventories (SSDP/ARP/scan results) rather than mangling them; `strict` keeps only metrics,
statuses and finding codes. Pseudonyms are consistent within a document and — by default — not
across documents, so an upload endpoint cannot link a device's runs; a stable salt is opt-in for
people diffing their own history. Classification is one readable table, not annotations spread
across modules. 14 tests, each pinning a property someone's privacy depends on.
- **Server-side upload policy** — `off | anonymous | account`, plus max size, retention days, max
runs per device, and the *least* anonymization accepted. The profile advertises all of it so the
app presents the choice honestly instead of discovering the rules by being rejected. `account`
refuses today rather than falling back to anonymous: picking the strict setting before OIDC
lands must not silently mean the loose one.
**The archive holds the unredacted document; redaction happens on the way out, per upload.** The
local archive is the user's own data on their own device, and redacting it would destroy exactly
the detail that makes a week-old run worth keeping.
App-side: settings screen (archive limits, privacy level with a plain-language description of what
each keeps, auto-upload off by default, server URL/pin/credential), history screen showing whether
each run left the device, and a **preview of the exact bytes an upload would send** — an anonymizer
the user cannot inspect is only a promise.
Live round trip against fmr: uploaded a run, listed it, fetched it back and asserted the SSID, the
SSDP neighbour name and the free-text note are absent from what the server stores while the
finding code and the metrics survive, then deleted it.
### Still open
- `mtu.pmtud_up` (DF + errqueue), `frag_send`, `throughput`, TRAIN_REPORT retrieval.
- Enrollment UI in the app (server URL/pin/credential are typed in by hand today).
- Accounts/OIDC on the server, which is what `uploads=account` is waiting for.
- Nothing in this entry has been exercised on a phone yet — all of it was verified from the PC
against the live server. On-device verification is the next step.
### SemVer compatibility windows between app and server (server-v0.5.0 … v0.5.2, 2026-08-01)
Both artifacts are SemVer, and each now declares — and enforces — which peer versions it will talk
to. Spec: `docs/probe-protocol.md` §8.
**Two axes, deliberately not conflated.** Release versions are a *proxy* for what actually has to
match, so the real thing is checked first:
- `protocol_version` — **can** these builds talk. Advertised in the profile; a peer in a different
breaking series is refused whatever its release version says. Below 1.0.0 the **minor** is the
breaking axis (SemVer §4).
- release-version window — **may** they, per policy. `[min, max)`, min inclusive, max exclusive,
because the useful bound is always "the version that broke it".
Bounds sit at breaking boundaries, not at releases, so shipping a patch never requires editing a
range. The app requires server `>= 0.4.2` for a stated reason, not caution: earlier multi-homed
servers mis-addressed granted sends and the client measured 100 % downstream loss that never
happened. Operators override the server side with `ECHOLOT_MIN_APP_VERSION` /
`ECHOLOT_MAX_APP_VERSION`; a malformed bound is fatal at startup rather than ignored, so a typo
cannot silently disable a restriction.
Three rules that shaped the implementation:
1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs;
gating it leaves the user with a network error instead of an answer.
2. **An unparseable or absent version is `unknown`, and is allowed.** Dev builds report `dev`, and a
client too old to send the header cannot be identified anyway.
3. **Refusal is 426 with a body naming both versions and the window**, surfaced client-side as a
distinct `VersionRefused` rather than folded into "network error".
The app's `versionCode` is now derived from its SemVer (`major*1e6 + minor*1e4 + patch*10`) instead
of being a second number to remember.
Verified live against fmr (`LiveCompatTest`): profile advertises the window and stays readable for a
refused version; 0.1.0 and 99.0.0 are both refused with actionable messages; 0.2.0 and a missing
header are both served.
One user-visible bug caught in the process: Go's JSON encoder HTML-escapes `<`, `>` and `&` by
default, so the refusal reached the client as `needs \u003e= 0.2.0`. Disabled at the encoder (this
is an API, not a page), and the client now *parses* the error field instead of pattern-matching it,
so it survives whatever a future encoder decides to escape.
### Enrollment: the server mints the bootstrap link (server-v0.5.3 … v0.5.4, 2026-08-01)
Until now a device was configured by hand-typing a control URL, a base64 SPKI pin and a
credential. That is the step that goes wrong, and it goes wrong quietly: a pin off by one
character does not fail loudly, it just never matches, and surfaces days later as an inscrutable
TLS error.
`POST /admin/enroll-tokens` now returns the whole §2.1 bootstrap link alongside the token, because
the server is the only party holding all three parts at once. The app takes it from a paste or an
`echolot://enroll` deep link (so a QR scan configures a server in one action) and writes URL, pin
and credential **together or not at all** — a half-applied server fails later, somewhere else,
with an error pointing at the wrong thing.
The control URL comes from `ECHOLOT_PUBLIC_URL` (set on fmr to `https://fmr-1.echo-lot.app:8443`),
falling back to the first control listen address; a wildcard bind warns rather than emitting a
link to `0.0.0.0`.
**The encoding trap, which is the whole reason this is tested across both languages.** The pin is
base64, so it contains `+`, `/` and `=` — each of which means something else in a query string. An
unencoded `+` decodes to a space, leaving the pin wrong by exactly one character. Base64 has no
spaces, so the parser restores them; that cannot damage a correctly-encoded pin and it rescues
every hand-assembled link. `LiveEnrollmentTest` redeems a link the *server* produced, which is the
only way to catch a disagreement between the Go assembler and the Kotlin parser — a unit test on
either side alone cannot see it. It also asserts the token is refused the second time.
Also fixed a spec divergence found while reading §2.1: the spec names the field
`device_credential`, the first implementation shipped `credential`. The server now sends both and
the client prefers the spec's; the alias goes once nothing reads it.
Two process notes from this round:
- An edit to the admin handler silently failed to apply and the endpoint kept returning just the
token. Caught by deploying and *looking at the response*, not by trusting a green build.
- The live suite is now six tests (`LiveServerTest`, `LiveMeasurement`, `LiveGranted`,
`LiveUpload`, `LiveCompat`, `LiveEnrollment`), all green against fmr from the PC with no device.
### Directional loss: which way is the packet loss? (2026-08-01)
A round trip can only report that *something* was lost somewhere, which is the least useful form
of the answer — "3 % loss" sends an engineer looking in both directions at once. The server
already records every packet it received per sequence number (§6), so the two cases are actually
distinguishable, and `train.udp_updown` now reports them separately:
- sent, never seen by the server → **upstream** loss
- seen by the server, reply never arrived → **downstream** loss
Findings name the direction and say what is *not* implicated, which is half the value:
`connectivity.loss_upstream` ("the return path is not implicated: replies came back for everything
that arrived"), `connectivity.loss_downstream`, `nat.udp_unreachable_upstream`.
Two things the implementation gets deliberately right:
- **Downstream loss is measured against what reached the server**, not against what was sent.
Using "sent" as the denominator counts every upstream loss a second time and overstates the
return path. Pinned by a test with loss in both directions at once.
- **Per-direction jitter without synchronised clocks.** Absolute one-way delay would need clock
sync and we deliberately have none (the two-clock rule). But `server_rx client_tx` carries a
constant unknown offset, and differencing successive samples cancels it — so RFC 3393 one-way
delay variation *is* honestly attributable to a direction even though latency is not. A test
pins that a 10-second clock offset changes nothing.
Correlation is by **wire sequence number**, which is not the loop index: the counter is shared
with every other packet type on the session, so "the nth echo" is not "sequence n". `ProbeSession`
now exposes `lastSeq`, including for a probe that was lost — a lost packet still has a sequence
number, and that number is exactly what tells you which way it was lost.
Live against fmr: 20/20 both ways, and jitter of **0.08 ms upstream vs 0.85 ms downstream** — a
tenfold asymmetry that a round-trip measurement cannot see at all.
10 unit tests on the arithmetic (a wrong denominator here does not crash, it produces a plausible
number pointing at the wrong half of the network) plus the live correlation check.
+91 -5
View File
@@ -24,11 +24,34 @@ echolot://enroll?v=1&u=<control-URL, urlencoded>&p=pin-sha256:<b64 SPKI hash>&t=
```
POST /v1/enroll Authorization: Bearer <enrollment-token>
→ 200 { "device_credential": "<random 256-bit, b64url>",
"device_id": "uuid",
"profile": { ... §2.2 ... } }
→ 201 { "device_credential": "<random 256-bit, b64url>",
"device_id": "uuid" }
```
The **server assembles the bootstrap link**, because it is the only party holding all three parts
at once, and the part an operator gets wrong by hand is the base64 pin — which does not fail
loudly, it just never matches, and surfaces later as an inscrutable TLS error:
```
POST /admin/enroll-tokens
→ { "token": "…", "expires_in_s": 86400,
"enroll_uri": "echolot://enroll?v=1&u=…&p=…&t=…" }
```
The control URL in the link comes from `ECHOLOT_PUBLIC_URL`, falling back to the first control
listen address. A wildcard bind has no single right answer, so it warns rather than guessing.
Encoding notes that matter in practice:
- `u`, `p` and `t` are **percent-encoded**. The pin is base64, so it contains `+`, `/` and `=`,
every one of which means something else in a query string.
- A `+` that was *not* encoded decodes to a space. Base64 contains no spaces, so a parser SHOULD
restore them — the alternative is a pin wrong by one character and a failure that points nowhere
near the cause.
- The control URL MUST be `https://`. The pin only protects a TLS connection; a cleartext URL
would hand the token to anyone on the path.
- **The link is a secret** while it is live: it carries a bearer token, so anyone who sees it
before the device does can enroll instead.
Enrollment tokens are single-use with expiry, created in the admin UI, scoped `enroll`. The device credential is a long-lived bearer secret, scoped `run-tests`; it is also the HKDF input for session keys. Revocation = deleting the device in the admin UI.
### 2.2 Profile
@@ -192,14 +215,77 @@ Note: exact RDATA constants to be frozen in the implementation's `dns_reference.
Same daemon, separate listener (default localhost-only): health + self-test (are both IPs live, is the canary zone delegated correctly, is UDP reachable from outside — tested via a public echolot "mirror" if configured), enrollment token management (create/expire/scope), device list + revocation, retention settings, QR rendering (client-side JS). Out of scope for this spec beyond the endpoints above.
## 8. Cross-references to the measurement schema
## 8. Version compatibility
Both artifacts are versioned with **SemVer**: the Go server (`server-vX.Y.Z` tags) and the Android
app (`versionName`; `versionCode` is derived from it, never maintained separately). Two independent
things are checked, and conflating them is the mistake this section exists to prevent.
### 8.1 Protocol version — *can* these builds talk?
`protocol_version` is the version of **this document**. It is advertised in the profile
(`compat.protocol_version`) and is the correctness axis: a peer in a different breaking series
cannot be talked to, whatever its release version says. Below `1.0.0` the **minor** is the breaking
axis (SemVer §4); at and above it, the major is. A patch bump of the protocol never splits a fleet.
### 8.2 Release-version window — *should* they, per policy?
Each side declares the range of peer release versions it will work with, as `[min, max)`
**minimum inclusive, maximum exclusive**, because the useful bound is always "the version that
broke it" and writing that literally is unambiguous. An empty maximum means unbounded.
The server advertises its window and enforces it:
```jsonc
"compat": {
"protocol_version": "1.0.0",
"schema_version": "1.0.0",
"app_min": "0.2.0",
"app_max": "1.0.0" // exclusive; "" = no upper bound
}
```
Operators override it with `ECHOLOT_MIN_APP_VERSION` / `ECHOLOT_MAX_APP_VERSION` (or
`--min-app-version` / `--max-app-version`). A malformed bound is **fatal at startup**, not ignored:
a typo must not silently disable a restriction the operator meant to set.
The app sends its version on every control-plane request:
```
X-Echolot-App-Version: 0.2.0
```
and carries its own bounds for the server (`MIN_SERVER` / `MAX_SERVER` in `Compat.kt`). It checks
the profile in **both** directions — is the server in our range, and are we in the server's — so a
mismatch is reported before a run starts rather than discovered halfway through one.
### 8.3 Rules
1. **`GET /v1/profile` is never gated.** It is where a refused client learns which version it needs.
Gating it leaves the user with a network error instead of an answer, defeating the check.
2. **Refusal is `426 Upgrade Required`**, with a body naming both versions and the accepted window:
```json
{ "error": "app 0.1.0 is older than this build supports (needs >= 0.2.0, < 1.0.0). Update the app.",
"app_version": "0.1.0", "accepts_app": ">= 0.2.0, < 1.0.0",
"server_version": "0.5.0", "protocol_version": "1.0.0" }
```
3. **An unparseable or absent version is `unknown`, and is allowed.** Development builds report
`dev`, and a client too old to send the header cannot be identified anyway. The check exists to
turn confusing failures into clear ones; refusing what it cannot identify does the opposite.
4. **Bounds move at breaking boundaries, not at releases.** Shipping a patch must never require
editing a range. A minimum is raised only when older peers are actually harmful — e.g. the app
requires server `>= 0.4.2` because earlier multi-homed servers sent granted traffic from an
address the session never used, which the client measured as 100 % downstream loss. A
confidently wrong measurement is worse than a refused one.
## 9. Cross-references to the measurement schema
- Observation-block fields (§3.3) appear as `*_seen_by_server` columns in `train` evidence (schema §6.2).
- `TIMESYNC` (§3.2) produces the `time.server_offset` test; without it, cross-clock fields must not be compared (schema §6.2 note).
- Capability strings (§2.3) are copied verbatim into `server_sessions[].capabilities` (schema §5); tests skipped for missing capability get `status: "unsupported"`.
- `session_id` maps to `server_sessions[].session_id`; `action_id`s appear in test `params`.
## 9. Open items
## 10. Open items
1. Whether TRAIN_REPORT should also stream during long trains (partial reports every N packets) for live UI feedback — leaning yes, same type with a `flags` bit.
2. Throughput methodology (fixed streams vs BBR-style ramp) — decide with `perf.*` test design.
+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
+83
View File
@@ -0,0 +1,83 @@
// 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)
}
// The app's version is SemVer and lives here, once. versionCode is derived from it rather than
// maintained alongside: Play/F-Droid need a monotonically increasing integer, but a second number
// that a human has to remember to bump is a number that eventually disagrees with the first — and
// the version is now load-bearing, since the server decides whether to serve us by it.
//
// major*1_000_000 + minor*10_000 + patch*10 leaves room for 9 patch-level rebuilds (the trailing
// digit) without disturbing the mapping, and stays inside the 2_100_000_000 ceiling until major 2100.
val appVersionName = "0.2.0"
fun versionCodeOf(semver: String): Int {
val (major, minor, patch) = semver.substringBefore('-').split(".").map(String::toInt)
return major * 1_000_000 + minor * 10_000 + patch * 10
}
android {
namespace = "app.echo_lot.app"
compileSdk = 36
defaultConfig {
applicationId = "app.echo_lot.app"
minSdk = 26
targetSdk = 36
versionCode = versionCodeOf(appVersionName)
versionName = appVersionName
// 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\"")
// The bare SemVer, without the debug build's "-dev" suffix stripped away by the server's
// parser anyway — sent to servers so they can apply their compatibility window.
buildConfigField("String", "APP_SEMVER", "\"$appVersionName\"")
}
buildTypes {
release { isMinifyEnabled = false }
debug {
// The dev build is a separate app: own package (installs alongside a real
// Echolot), own label and a DEV-badged icon (src/debug/res).
applicationIdSuffix = ".dev"
versionNameSuffix = "-dev"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(project(":core-measurement"))
implementation(project(":core-protocol"))
implementation(project(":core-engine"))
implementation(project(":core-probe"))
implementation(project(":core-shizuku"))
implementation(project(":core-privacy"))
implementation(project(":core-archive"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
debugImplementation(libs.androidx.ui.tooling)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 948 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later
Debug foreground: the production mark with a DEV ribbon so the dev build is
unmistakable on the launcher next to a real install. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_launcher_foreground"/>
<item android:drawable="@drawable/ic_dev_badge"/>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_dev"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Echolot</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<resources>
<style name="Theme.Echolot" parent="android:Theme.Material.NoActionBar" />
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<paths>
<cache-path name="reports" path="reports/" />
</paths>
+11
View File
@@ -0,0 +1,11 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The on-device run archive: measurement documents on disk, with retention.
// Pure Kotlin/JVM (it takes a directory, not a Context) so the retention rules
// — the part with edge cases — are unit-testable without a device.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package archive keeps completed measurement runs on the device.
//
// The point of an archive is the second run: "this network was fine on Tuesday" is only
// answerable if Tuesday was kept. But an app that silently accumulates network dumps forever is
// its own privacy problem, so retention is a first-class part of the type rather than a cleanup
// job somebody remembers to write — every save enforces it.
//
// Storage is one JSON file per run plus a small index entry, in a plain directory. Nothing here
// needs a database, and a plain directory is something a user can inspect, copy off, or delete
// with a file manager. Files are written to a temp name and renamed, so a run interrupted mid-
// write never leaves a half-document that reads as real.
package app.echo_lot.archive
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.io.File
/** Index entry for one archived run — enough for a history list without opening the documents. */
@Serializable
data class ArchivedRun(
val id: String,
@SerialName("saved_at_epoch_ms") val savedAtEpochMs: Long,
@SerialName("started_at") val startedAt: String? = null,
val verdict: String? = null,
@SerialName("finding_count") val findingCount: Int = 0,
@SerialName("size_bytes") val sizeBytes: Long = 0,
val anonymization: String = "full",
/** Whether this run has been accepted by a server, so history can show what is backed up. */
val uploaded: Boolean = false,
@SerialName("uploaded_to") val uploadedTo: String? = null,
)
/**
* Retention limits. All three are independent ceilings; a run is dropped when it violates any of
* them. Zero disables that limit.
*
* The default keeps a hundred runs or three months, whichever comes first. That is enough to see
* a pattern ("it degrades every evening") without turning the phone into an archive of every
* network its owner ever walked past.
*/
@Serializable
data class RetentionPolicy(
/**
* Whether to archive at all. Separate from the limits because "no limits" (every limit zero)
* and "keep nothing" are opposite intentions, and collapsing them onto the same value is how
* a user who turns all the caps off ends up with an empty history.
*/
val enabled: Boolean = true,
@SerialName("max_runs") val maxRuns: Int = 100,
@SerialName("max_age_days") val maxAgeDays: Int = 90,
@SerialName("max_total_bytes") val maxTotalBytes: Long = 64L * 1024 * 1024,
) {
companion object {
/** Archiving off: runs are shown once and never written. */
val KeepNothing = RetentionPolicy(enabled = false)
/** Archiving on with no ceilings. Every run is kept until the user deletes it. */
val Unlimited = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = 0)
val Default = RetentionPolicy()
}
val keepsAnything: Boolean get() = enabled
}
/** What a purge removed, so the UI can say "dropped 3 old runs" instead of silently deleting. */
data class PurgeResult(val removed: List<String>, val freedBytes: Long) {
val isEmpty: Boolean get() = removed.isEmpty()
}
class RunArchive(private val dir: File, private val now: () -> Long = System::currentTimeMillis) {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
init {
dir.mkdirs()
}
/**
* Writes one run and applies retention. Returns the index entry, or null when the policy
* keeps nothing at all — in which case nothing is written, rather than written and instantly
* deleted (the difference matters on flash storage and to anyone watching the filesystem).
*/
fun save(runJson: String, policy: RetentionPolicy = RetentionPolicy.Default): ArchivedRun? {
if (!policy.keepsAnything) return null
val doc = runCatching { json.parseToJsonElement(runJson).jsonObject }.getOrNull() ?: return null
val meta = indexOf(doc, runJson.toByteArray().size.toLong()) ?: return null
writeAtomically(File(dir, meta.id + EXT), runJson)
writeAtomically(File(dir, meta.id + META_EXT), json.encodeToString(ArchivedRun.serializer(), meta))
purge(policy)
return meta
}
/** History, newest first. */
fun list(): List<ArchivedRun> =
(dir.listFiles { f -> f.name.endsWith(META_EXT) } ?: emptyArray())
.mapNotNull { f ->
runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
}
.sortedByDescending { it.savedAtEpochMs }
fun read(id: String): String? = File(dir, safe(id) + EXT).takeIf { it.isFile }?.readText()
fun delete(id: String): Boolean {
val s = safe(id)
val doc = File(dir, s + EXT).delete()
File(dir, s + META_EXT).delete()
return doc
}
fun deleteAll(): Int = list().count { delete(it.id) }
/** Records that a server accepted this run, so history can distinguish backed-up from local. */
fun markUploaded(id: String, serverName: String) {
val f = File(dir, safe(id) + META_EXT)
val meta = runCatching { json.decodeFromString(ArchivedRun.serializer(), f.readText()) }.getOrNull()
?: return
writeAtomically(
f,
json.encodeToString(
ArchivedRun.serializer(),
meta.copy(uploaded = true, uploadedTo = serverName),
),
)
}
fun totalBytes(): Long = list().sumOf { it.sizeBytes }
/**
* Enforces the policy. Age first, then total size, then count: dropping stale runs may already
* satisfy the other two, and it is the limit a user reasons about ("keep three months"), so it
* should not be pre-empted by a size sweep deleting last week instead.
*/
fun purge(policy: RetentionPolicy): PurgeResult {
val removed = ArrayList<String>()
var freed = 0L
fun drop(r: ArchivedRun) {
if (delete(r.id)) {
removed.add(r.id)
freed += r.sizeBytes
}
}
var kept = list()
if (policy.maxAgeDays > 0) {
val cutoff = now() - policy.maxAgeDays * 24L * 60 * 60 * 1000
val (fresh, stale) = kept.partition { it.savedAtEpochMs >= cutoff }
stale.forEach(::drop)
kept = fresh
}
if (policy.maxTotalBytes > 0) {
var total = kept.sumOf { it.sizeBytes }
// Oldest first until we are under the ceiling.
for (r in kept.reversed()) {
if (total <= policy.maxTotalBytes) break
drop(r)
total -= r.sizeBytes
}
kept = kept.filter { it.id !in removed }
}
if (policy.maxRuns > 0 && kept.size > policy.maxRuns) {
kept.drop(policy.maxRuns).forEach(::drop) // list() is newest-first
}
return PurgeResult(removed, freed)
}
// ---- internals ---------------------------------------------------------------------
private fun indexOf(doc: JsonObject, size: Long): ArchivedRun? {
val run = doc["run"]?.jsonObject ?: return null
val id = run["id"]?.jsonPrimitive?.content?.let(::safe)?.takeIf { it.isNotEmpty() } ?: return null
return ArchivedRun(
id = id,
savedAtEpochMs = now(),
startedAt = run["started_at"]?.jsonPrimitive?.content,
verdict = doc["summary"]?.jsonObject?.get("verdict")?.jsonPrimitive?.content,
findingCount = (doc["findings"] as? kotlinx.serialization.json.JsonArray)?.size ?: 0,
sizeBytes = size,
anonymization = run["privacy"]?.jsonObject?.get("anonymization")?.jsonPrimitive?.content ?: "full",
)
}
private fun writeAtomically(target: File, content: String) {
val tmp = File(target.parentFile, target.name + ".tmp")
tmp.writeText(content)
if (!tmp.renameTo(target)) {
target.delete()
tmp.renameTo(target)
}
}
/** Run ids reach the filesystem; keep them to characters that cannot climb out of [dir]. */
private fun safe(id: String): String = buildString {
for (c in id) if (c.isLetterOrDigit() || c == '-' || c == '_') append(c)
}.take(64)
private companion object {
const val EXT = ".json"
const val META_EXT = ".meta.json"
}
}
@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.archive
import java.io.File
import java.nio.file.Files
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class RunArchiveTest {
private val dir: File = Files.createTempDirectory("echolot-archive").toFile()
private var clock = 1_000_000_000_000L // fixed: retention is time arithmetic, not wall time
private fun archive() = RunArchive(dir) { clock }
@AfterTest fun cleanup() { dir.deleteRecursively() }
private fun doc(id: String, findings: Int = 1, pad: Int = 0): String {
val f = (1..findings).joinToString(",") { """{"id":"f$it"}""" }
return """{"run":{"id":"$id","started_at":"2026-08-01T10:00:00Z","privacy":{"anonymization":"balanced"}},""" +
""""findings":[$f],"summary":{"verdict":"warn"},"pad":"${"x".repeat(pad)}"}"""
}
@Test
fun savedRunsComeBackNewestFirst() {
val a = archive()
for (i in 1..3) {
a.save(doc("run-$i"))
clock += 60_000
}
assertEquals(listOf("run-3", "run-2", "run-1"), a.list().map { it.id })
}
@Test
fun theIndexSummarisesTheDocument() {
val meta = assertNotNull(archive().save(doc("run-1", findings = 4)))
assertEquals("warn", meta.verdict)
assertEquals(4, meta.findingCount)
assertEquals("balanced", meta.anonymization)
assertEquals("2026-08-01T10:00:00Z", meta.startedAt)
assertFalse(meta.uploaded)
}
@Test
fun theDocumentComesBackByteForByte() {
val a = archive()
val original = doc("run-1")
a.save(original)
assertEquals(original, a.read("run-1"))
}
@Test
fun countLimitKeepsTheNewest() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 3, maxAgeDays = 0, maxTotalBytes = 0)
for (i in 1..7) {
a.save(doc("run-$i"), policy)
clock += 60_000
}
assertEquals(listOf("run-7", "run-6", "run-5"), a.list().map { it.id })
assertNull(a.read("run-1"), "purged run's document should be gone, not just its index entry")
}
@Test
fun ageLimitDropsRunsPastTheWindow() {
val a = archive()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 7, maxTotalBytes = 0)
a.save(doc("old"), policy)
clock += 30L * 24 * 60 * 60 * 1000 // a month later
a.save(doc("new"), policy)
assertEquals(listOf("new"), a.list().map { it.id })
}
@Test
fun sizeLimitDropsOldestUntilUnderTheCeiling() {
val a = archive()
val one = doc("x", pad = 900).toByteArray().size.toLong()
val policy = RetentionPolicy(maxRuns = 0, maxAgeDays = 0, maxTotalBytes = one * 2 + 10)
for (i in 1..5) {
a.save(doc("run-$i", pad = 900), policy)
clock += 60_000
}
val kept = a.list()
assertTrue(kept.size <= 2, "size ceiling not enforced: kept ${kept.size}")
assertEquals("run-5", kept.first().id, "the newest run must always survive")
assertTrue(a.totalBytes() <= policy.maxTotalBytes)
}
// A policy that keeps nothing must not write-then-delete: the run should never touch storage.
@Test
fun keepNothingWritesNothing() {
val a = archive()
assertNull(a.save(doc("run-1"), RetentionPolicy.KeepNothing))
assertTrue(a.list().isEmpty())
assertEquals(0, dir.listFiles()?.size ?: 0, "files were written for a keep-nothing policy")
}
// "no ceilings" and "keep nothing" must not be the same policy, however a user arrives at
// one: turning every limit off should keep everything, not wipe the history.
@Test
fun unlimitedKeepsEverythingWhileKeepNothingKeepsNone() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
assertEquals(5, a.list().size)
assertNull(a.save(doc("run-6"), RetentionPolicy.KeepNothing))
assertEquals(5, a.list().size, "keep-nothing must not touch what is already archived")
}
@Test
fun uploadStateIsRecorded() {
val a = archive()
a.save(doc("run-1"))
a.markUploaded("run-1", "fmr")
val meta = a.list().single()
assertTrue(meta.uploaded)
assertEquals("fmr", meta.uploadedTo)
assertEquals("run-1", meta.id, "marking upload must not disturb the rest of the entry")
}
@Test
fun deleteRemovesBothFiles() {
val a = archive()
a.save(doc("run-1"))
assertTrue(a.delete("run-1"))
assertTrue(a.list().isEmpty())
assertNull(a.read("run-1"))
assertEquals(0, dir.listFiles()?.size ?: 0)
}
@Test
fun malformedInputIsRejectedRatherThanArchived() {
val a = archive()
assertNull(a.save("not json"))
assertNull(a.save("""{"summary":{"verdict":"ok"}}"""), "a document with no run id has no identity")
assertTrue(a.list().isEmpty())
}
// Run ids come from a document that may have been produced elsewhere; they must not be able
// to write outside the archive directory.
@Test
fun runIdsCannotEscapeTheArchiveDirectory() {
val a = archive()
a.save(doc("../../evil"))
val strays = dir.parentFile.listFiles { f -> f.name.contains("evil") } ?: emptyArray()
assertTrue(strays.isEmpty(), "wrote outside the archive: ${strays.toList()}")
}
@Test
fun purgeReportsWhatItRemoved() {
val a = archive()
for (i in 1..5) {
a.save(doc("run-$i"), RetentionPolicy.Unlimited)
clock += 60_000
}
val result = a.purge(RetentionPolicy(maxRuns = 2, maxAgeDays = 0, maxTotalBytes = 0))
assertEquals(3, result.removed.size)
assertTrue(result.freedBytes > 0)
assertEquals(2, a.list().size)
}
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The measurement run engine: composes core-protocol probes into
// core-measurement documents. Pure Kotlin/JVM, so it is unit-testable and can
// run a full server-facing measurement against a live server.
dependencies {
implementation(project(":core-protocol"))
implementation(project(":core-measurement"))
implementation(project(":core-privacy"))
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test {
useJUnitPlatform()
listOf("ECHOLOT_LIVE_URL","ECHOLOT_LIVE_PIN","ECHOLOT_LIVE_CRED","ECHOLOT_LIVE_UDP","ECHOLOT_LIVE_TARGET","ECHOLOT_ENROLL_URI")
.forEach { k -> System.getenv(k)?.let { environment(k, it) } }
}
@@ -0,0 +1,107 @@
// 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
/**
* Splits a round-trip train into its two directions using what the server witnessed.
*
* A round trip can only report that *something* was lost somewhere. That is the least useful form
* of the answer: "3 % loss" sends an engineer looking in both directions at once. The server
* records every packet it received, per sequence number (probe-protocol.md §6), so the two cases
* are actually distinguishable:
*
* - sent, never seen by the server → **upstream** loss
* - seen by the server, reply never arrived → **downstream** loss
*
* The same records give one-way delay *variation* per direction. Absolute one-way delay would
* need synchronised clocks and we deliberately have none (measurement-schema.md's two-clock rule),
* but the variation does not: (server_rx client_tx) contains an unknown constant clock offset,
* and differencing successive samples cancels it. So jitter is honestly attributable to a
* direction even though latency is not.
*/
object Directional {
/** One probe as the client saw it. [tRxNs] null means no reply came back. */
data class Sample(val seq: Int, val tTxNs: Long, val tRxNs: Long?)
/** One probe as the server saw it: its own receive and transmit stamps, on its own clock. */
data class ServerSighting(val seq: Int, val tRxNs: Long, val tTxNs: Long)
fun analyse(sent: List<Sample>, seen: List<ServerSighting>): DirectionalMetrics {
val byServerSeq = seen.associateBy { it.seq }
// Only sequences we actually sent count. A server record for a sequence we have no note
// of is not evidence about this train — it is a bug or a stray, and silently folding it
// in would produce loss percentages above 100 or below zero.
val relevant = sent.filter { byServerSeq.containsKey(it.seq) }
val nSent = sent.size
val nSeen = relevant.size
val nReplied = sent.count { it.tRxNs != null }
// A reply can only exist if the request arrived, so downstream loss is measured against
// what the server saw, not against what we sent — otherwise upstream loss is counted twice.
val lostUp = nSent - nSeen
val lostDown = (nSeen - nReplied).coerceAtLeast(0)
val upDeltas = relevant.sortedBy { it.seq }
.map { byServerSeq.getValue(it.seq).tRxNs - it.tTxNs }
val downDeltas = sent.filter { it.tRxNs != null && byServerSeq.containsKey(it.seq) }
.sortedBy { it.seq }
.map { it.tRxNs!! - byServerSeq.getValue(it.seq).tTxNs }
return DirectionalMetrics(
sent = nSent,
seenByServer = nSeen,
repliesReceived = nReplied,
lostUpstream = lostUp,
lostDownstream = lostDown,
lossUpstreamPct = pct(lostUp, nSent),
// Denominator is what reached the server: of the packets that got there, how many
// replies came back.
lossDownstreamPct = pct(lostDown, nSeen),
jitterUpstreamMs = jitterMs(upDeltas),
jitterDownstreamMs = jitterMs(downDeltas),
/** True when the server saw nothing at all, which is a different fault from loss. */
noneReachedServer = nSent > 0 && nSeen == 0,
)
}
/**
* Mean absolute difference between consecutive one-way samples (RFC 3393 IPDV, averaged).
*
* Differencing is what makes this legitimate without synchronised clocks: each sample carries
* the same unknown offset between the two clocks, and the difference cancels it. Fewer than
* two samples yields null rather than zero — "no jitter" and "not enough data to say" are
* different claims and only one of them is true here.
*/
private fun jitterMs(oneWayNs: List<Long>): Double? {
if (oneWayNs.size < 2) return null
val deltas = oneWayNs.zipWithNext { a, b -> kotlin.math.abs(b - a) }
return round2(deltas.average() / 1_000_000.0)
}
private fun pct(part: Int, whole: Int): Double =
if (whole <= 0) 0.0 else round2(part * 100.0 / whole)
private fun round2(v: Double) = Math.round(v * 100.0) / 100.0
}
/** Directional metrics for train.udp_updown; recomputable from the columnar evidence. */
@Serializable
data class DirectionalMetrics(
val sent: Int,
@SerialName("seen_by_server") val seenByServer: Int,
@SerialName("replies_received") val repliesReceived: Int,
@SerialName("lost_upstream") val lostUpstream: Int,
@SerialName("lost_downstream") val lostDownstream: Int,
@SerialName("loss_upstream_pct") val lossUpstreamPct: Double,
@SerialName("loss_downstream_pct") val lossDownstreamPct: Double,
/** One-way delay variation (RFC 3393), per direction. Null when there were too few samples. */
@SerialName("jitter_upstream_ms") val jitterUpstreamMs: Double? = null,
@SerialName("jitter_downstream_ms") val jitterDownstreamMs: Double? = null,
@SerialName("none_reached_server") val noneReachedServer: Boolean = false,
)
@@ -0,0 +1,485 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.*
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
/**
* The measurements only the far end can make: what the *downstream* path does to traffic the
* client never asked for packet-by-packet.
*
* A client alone can measure a round trip, and it can find the largest packet it can *send*. It
* cannot find the largest packet it can *receive*, or whether the network drops downstream
* packets independently of upstream ones — those need a server willing to push, which is why the
* protocol gates them behind an asymmetric grant (probe-protocol.md §3.4).
*
* Three separate facts come out, and keeping them separate is the point:
* - `mtu.pmtud_down` — the largest datagram that arrives *unfragmented*. This is the number
* that matters for anything setting DF, and it is only meaningful because the server sets DF.
* - `mtu.frag_delivery` — whether larger datagrams arrive once the network is allowed to
* fragment them. A path can be fine for one and broken for the other; conflating them is how
* you get "MTU is 4000" on a link that drops every DF packet over 1400.
* - `train.udp_downstream` — loss, reordering and arrival spacing in the download direction.
*/
class DownstreamMeasurement(private val ids: IdSource) {
private val json = Json { encodeDefaults = true; explicitNulls = true }
/** How long to wait for a granted burst after the server accepts the action. */
private val collectWindowMs = 4_000L
/**
* Shorter, but long enough to cover the first_last mode's deliberate 250 ms hold plus a
* reassembly. A fragment burst is one datagram: it is here quickly or not at all.
*/
private val fragWindowMs = 1_500L
/**
* Asks the server to send one deliberately-fragmented datagram per ordering, and reports
* which orderings survive the path.
*
* Kernel fragmentation always emits fragments in order, first one first, so an oversized
* datagram can only answer "do fragments get through at all". The interesting fault is about
* ordering: only the *first* fragment carries the UDP ports, so a stateful firewall that has
* not seen it has nothing to match the rest against, and many drop them. That failure is
* invisible to every in-order test and shows up in the field as "large DNS answers fail here"
* or "the tunnel breaks when the MTU drops".
*/
fun fragmentOrdering(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
sizeBytes: Int = 2000,
fragBytes: Int = 576,
): Pair<Test, List<Finding>> {
val testId = ids.uuid()
val started = ids.monoNs()
val delivered = LinkedHashMap<String, Boolean>()
val fragmentCounts = LinkedHashMap<String, Int>()
var unsupported = false
for (mode in FRAG_MODES) {
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"frag_send","size_bytes":$sizeBytes,"mode":"$mode","frag_bytes":$fragBytes}""",
)
}
if (reply.isFailure) {
// A server without a raw socket says so; that is a missing capability, not a
// property of the network, and must not be recorded as a failed delivery.
unsupported = true
break
}
parseInt(reply.getOrNull(), "fragments")?.let { fragmentCounts[mode] = it }
// The burst is already on the wire when the action returns (it is sent
// synchronously), so anything that survived is either here or lost.
val got = probe.collectGranted(fragWindowMs).any { it.type == Wire.TYPE_FRAG_DATA }
delivered[mode] = got
}
if (unsupported) {
return Test(
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("no_raw_socket", "this server cannot craft fragments"),
) to emptyList()
}
val metrics = json.encodeToJsonElement(
FragOrderingMetrics(
sizeBytes = sizeBytes,
fragBytes = fragBytes,
fragmentsPerBurst = fragmentCounts,
deliveredByMode = delivered,
inOrderDelivered = delivered[FRAG_IN_ORDER] == true,
reorderedDelivered = delivered[FRAG_REVERSED] == true,
delayedFirstDelivered = delivered[FRAG_FIRST_LAST] == true,
),
) as JsonObject
val findings = ArrayList<Finding>()
val inOrder = delivered[FRAG_IN_ORDER] == true
val reversed = delivered[FRAG_REVERSED] == true
val firstLast = delivered[FRAG_FIRST_LAST] == true
if (!inOrder) {
findings.add(
finding(
"mtu.fragments_blocked", Category.MTU, Severity.MEDIUM, testId,
"IP fragments do not reach this device",
"A fragmented datagram sent in the normal order never arrived. Anything that " +
"relies on fragmentation — large DNS answers over UDP, some VPN traffic — " +
"will fail here rather than slow down.",
),
)
} else if (!reversed || !firstLast) {
// The precise and useful finding: fragments work, but only if they arrive tidily.
val which = buildList {
if (!reversed) add("out of order")
if (!firstLast) add("with the first fragment delayed")
}.joinToString(" or ")
findings.add(
finding(
"mtu.fragment_reorder_sensitive", Category.MTU, Severity.LOW, testId,
"Fragments are dropped when they arrive $which",
"In-order fragments are delivered, but the same datagram sent $which is not. " +
"Something on the path only reassembles when the first fragment (the one " +
"carrying the UDP ports) arrives first — typical of a stateful firewall " +
"or NAT. It works until the network reorders, then fails intermittently, " +
"which is the hardest kind of fault to chase.",
),
)
}
return Test(
id = testId, type = TestType.MTU_FRAG_ORDERING, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = if (inOrder) TestStatus.OK else TestStatus.PARTIAL,
metrics = metrics,
) to findings
}
/**
* Runs all three against an already-primed session.
*
* [session] must already have sent at least one ECHO: the grant is bound to the source the
* server has actually observed, so an unprimed session gets a 409 rather than a grant. That
* is the anti-amplification rule doing its job, not an error to work around.
*/
fun run(
credential: String,
sessionId: String,
control: ControlClient,
probe: ProbeSession,
sessionRef: String,
sizes: List<Int> = DEFAULT_SIZES,
trainCount: Int = 100,
trainSizeBytes: Int = 300,
trainIntervalUs: Int = 3_000,
): Pair<List<Test>, List<Finding>> {
val tests = ArrayList<Test>()
val findings = ArrayList<Finding>()
val df = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = true)
val frag = bigSend(credential, sessionId, control, probe, sessionRef, sizes, df = false)
val train = downTrain(
credential, sessionId, control, probe, sessionRef, trainCount, trainSizeBytes, trainIntervalUs,
)
tests.add(df.test); tests.add(frag.test); tests.add(train.test)
// Fragment ordering only makes sense once we know fragments arrive at all; when they do
// not, the ordering variants would all report "not delivered" and read as three faults
// instead of one.
if (frag.largestDelivered != null) {
val (fragTest, fragFindings) =
fragmentOrdering(credential, sessionId, control, probe, sessionRef)
tests.add(fragTest)
findings.addAll(fragFindings)
}
// A downstream MTU below the classic 1500-byte Ethernet payload is worth saying out loud:
// it is the usual cause of "small requests work, large responses hang".
val pathMtu = df.largestDelivered
if (pathMtu != null && pathMtu > 0) {
val ipMtu = pathMtu + IP_UDP_OVERHEAD4
if (ipMtu < 1500) {
findings.add(
finding(
"mtu.reduced_downstream", Category.MTU, Severity.LOW, df.test.id,
"Downstream path MTU is $ipMtu bytes, below 1500",
"The largest datagram that reached this device without fragmenting was " +
"$pathMtu bytes of payload ($ipMtu on the wire). Tunnels (PPPoE, VPN, " +
"IPv6-in-IPv4) commonly do this; it is only a fault when something " +
"on the path also blocks the ICMP messages that let senders discover it.",
),
)
}
// The dangerous combination: unfragmented large packets vanish AND fragments do too,
// so a sender that never gets told will retransmit into a black hole.
val fragLargest = frag.largestDelivered ?: 0
if (fragLargest <= pathMtu && sizes.any { it > pathMtu }) {
findings.add(
finding(
"mtu.downstream_blackhole", Category.MTU, Severity.MEDIUM, frag.test.id,
"Datagrams above $pathMtu bytes are dropped downstream, fragmented or not",
"Nothing larger than $pathMtu bytes arrived, even when the network was " +
"free to fragment it. Traffic that relies on large responses will " +
"stall rather than fail cleanly.",
),
)
}
}
if (train.received == 0) {
findings.add(
finding(
"connectivity.downstream_blocked", Category.CONNECTIVITY, Severity.HIGH, train.test.id,
"No server-initiated packets arrived",
"The server sent ${train.sent} packets toward this device and none arrived, " +
"while the round-trip echo worked. Something on the path forwards replies " +
"but drops traffic the device did not individually solicit.",
),
)
} else if (train.lossPct >= 5.0) {
findings.add(
finding(
"connectivity.downstream_loss", Category.CONNECTIVITY, Severity.MEDIUM, train.test.id,
"Downstream loss of ${round1(train.lossPct)}%",
"${train.sent - train.received} of ${train.sent} packets sent toward this " +
"device were lost. Downstream loss is invisible to a round-trip test, " +
"which reports only that *something* was lost somewhere.",
),
)
}
if (train.reordered > 0) {
findings.add(
finding(
"connectivity.downstream_reorder", Category.CONNECTIVITY, Severity.LOW, train.test.id,
"${train.reordered} downstream packet(s) arrived out of order",
"Packets arrived in a different order than they were sent. Usually per-packet " +
"load balancing across links; harmless for most traffic, not for all of it.",
),
)
}
return tests to findings
}
// ---- big_send ---------------------------------------------------------------------
private class SizeResult(val test: Test, val largestDelivered: Int?)
private fun bigSend(
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
sessionRef: String, sizes: List<Int>, df: Boolean,
): SizeResult {
val testId = ids.uuid()
val started = ids.monoNs()
val requested = sizes.joinToString(",")
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"big_send","df":$df,"sizes_bytes":[$requested]}""",
)
}
if (reply.isFailure) {
return SizeResult(
Test(
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "big_send refused"),
),
null,
)
}
// The server tells us which sizes it actually put on the wire. With DF it refuses
// anything above its own egress MTU, and treating those as "lost downstream" would
// blame the client's network for our own limit.
val accepted = parseIntArray(reply.getOrNull(), "sizes_bytes").ifEmpty { sizes }
val serverMaxDf = parseInt(reply.getOrNull(), "max_df_bytes")
val arrived = probe.collectGranted(collectWindowMs)
.filter { it.type == Wire.TYPE_BIG_SEND }
.map { it.sizeBytes }
.distinct()
.sorted()
val largest = arrived.maxOrNull()
val metrics = json.encodeToJsonElement(
BigSendMetrics(
requestedBytes = sizes,
sentBytes = accepted,
deliveredBytes = arrived,
largestDeliveredBytes = largest,
dontFragment = df,
serverMaxDfBytes = serverMaxDf,
// Only meaningful for the DF run; the IP-level MTU is the payload plus headers.
pathMtuBytes = if (df && largest != null) largest + IP_UDP_OVERHEAD4 else null,
),
) as JsonObject
val status = when {
arrived.isEmpty() -> TestStatus.FAILED
arrived.size < accepted.size -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return SizeResult(
Test(
id = testId, type = if (df) TestType.MTU_PMTUD_DOWN else TestType.MTU_FRAG_DELIVERY,
sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = status, metrics = metrics,
),
largest,
)
}
// ---- downtrain --------------------------------------------------------------------
private class TrainResult(
val test: Test, val sent: Int, val received: Int, val lossPct: Double, val reordered: Int,
)
private fun downTrain(
credential: String, sessionId: String, control: ControlClient, probe: ProbeSession,
sessionRef: String, count: Int, sizeBytes: Int, intervalUs: Int,
): TrainResult {
val testId = ids.uuid()
val started = ids.monoNs()
val reply = runCatching {
control.action(
credential, sessionId,
"""{"action":"downtrain","count":$count,"size_bytes":$sizeBytes,"interval_us":$intervalUs}""",
)
}
if (reply.isFailure) {
return TrainResult(
Test(
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef,
tier = Tier.APP, startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = TestStatus.UNSUPPORTED,
error = TestError("action_refused", reply.exceptionOrNull()?.message ?: "downtrain refused"),
),
0, 0, 0.0, 0,
)
}
val sent = parseInt(reply.getOrNull(), "count") ?: count
val got = probe.collectGranted(collectWindowMs).filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val received = got.size
val lossPct = if (sent == 0) 0.0 else (sent - received) * 100.0 / sent
// Reordering: a packet whose sequence is below the highest already seen. Counting
// inversions rather than "not sorted" keeps one late packet from being reported as
// dozens of reorder events.
var highest = -1
var reordered = 0
for (p in got) {
if (p.seq < highest) reordered++ else highest = p.seq
}
// Columnar evidence per the schema: what arrived, when, and how big — so every metric
// above is recomputable by a reader who does not trust our arithmetic.
val evidence = TrainEvidence(
epochMonoNs = started,
seq = got.map { it.seq },
tTxNs = got.map { null },
tRxNs = got.map { it.tRxNs },
sizeBytes = got.map { it.sizeBytes },
).toEvidence()
val interArrival = got.zipWithNext { a, b -> (b.tRxNs - a.tRxNs) / 1_000_000.0 }
val metrics = json.encodeToJsonElement(
DownTrainMetrics(
sent = sent, received = received, lossPct = round1(lossPct),
reorderedPackets = reordered,
sizeBytes = sizeBytes,
interArrivalMsAvg = interArrival.average().takeIf { interArrival.isNotEmpty() }?.let(::round1),
interArrivalMsMax = interArrival.maxOrNull()?.let(::round1),
sendIntervalUs = intervalUs,
),
) as JsonObject
val status = when {
received == 0 -> TestStatus.FAILED
received < sent -> TestStatus.PARTIAL
else -> TestStatus.OK
}
return TrainResult(
Test(
id = testId, type = TestType.TRAIN_UDP_DOWNSTREAM, sessionRef = sessionRef, tier = Tier.APP,
startedMonoNs = started, endedMonoNs = ids.monoNs(),
status = status, evidence = evidence, metrics = metrics,
),
sent, received, lossPct, reordered,
)
}
// ---- helpers ----------------------------------------------------------------------
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)),
)
/** Minimal scalar extraction from the action reply; the shape is small and server-owned. */
private fun parseInt(body: String?, key: String): Int? =
body?.let { Regex("\"$key\"\\s*:\\s*(-?\\d+)").find(it)?.groupValues?.get(1)?.toIntOrNull() }
private fun parseIntArray(body: String?, key: String): List<Int> =
body?.let { b ->
Regex("\"$key\"\\s*:\\s*\\[([^\\]]*)\\]").find(b)?.groupValues?.get(1)
?.split(",")?.mapNotNull { it.trim().toIntOrNull() }
} ?: emptyList()
private companion object {
/** IPv4 (20) + UDP (8). The v6 case is 48; reported per-family once v6 sessions land. */
const val IP_UDP_OVERHEAD4 = 28
const val FRAG_IN_ORDER = "in_order"
const val FRAG_REVERSED = "reversed"
const val FRAG_FIRST_LAST = "first_last"
val FRAG_MODES = listOf(FRAG_IN_ORDER, FRAG_REVERSED, FRAG_FIRST_LAST)
/** Straddles the usual suspects: 1500 Ethernet, 1492 PPPoE, 1400-ish tunnels. */
val DEFAULT_SIZES = listOf(600, 1200, 1372, 1400, 1450, 1472, 1500, 2000, 4000)
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
/** Metrics for mtu.pmtud_down / mtu.frag_delivery. */
@Serializable
data class BigSendMetrics(
@SerialName("requested_bytes") val requestedBytes: List<Int>,
@SerialName("sent_bytes") val sentBytes: List<Int>,
@SerialName("delivered_bytes") val deliveredBytes: List<Int>,
@SerialName("largest_delivered_bytes") val largestDeliveredBytes: Int? = null,
@SerialName("dont_fragment") val dontFragment: Boolean,
/** The server's own DF ceiling; sizes above it were never sent and are not path evidence. */
@SerialName("server_max_df_bytes") val serverMaxDfBytes: Int? = null,
@SerialName("path_mtu_bytes") val pathMtuBytes: Int? = null,
)
/** Metrics for mtu.frag_ordering. */
@Serializable
data class FragOrderingMetrics(
@SerialName("size_bytes") val sizeBytes: Int,
@SerialName("frag_bytes") val fragBytes: Int,
@SerialName("fragments_per_burst") val fragmentsPerBurst: Map<String, Int>,
@SerialName("delivered_by_mode") val deliveredByMode: Map<String, Boolean>,
@SerialName("in_order_delivered") val inOrderDelivered: Boolean,
@SerialName("reordered_delivered") val reorderedDelivered: Boolean,
@SerialName("delayed_first_delivered") val delayedFirstDelivered: Boolean,
)
/** Metrics for train.udp_downstream. */
@Serializable
data class DownTrainMetrics(
val sent: Int,
val received: Int,
@SerialName("loss_pct") val lossPct: Double,
@SerialName("reordered_packets") val reorderedPackets: Int,
@SerialName("size_bytes") val sizeBytes: Int,
@SerialName("inter_arrival_ms_avg") val interArrivalMsAvg: Double? = null,
@SerialName("inter_arrival_ms_max") val interArrivalMsMax: Double? = null,
@SerialName("send_interval_us") val sendIntervalUs: Int,
)
@@ -0,0 +1,273 @@
// 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.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
/**
* 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,
/**
* Whether to ask the server to push traffic back (downstream MTU and downstream train).
* Costs a few hundred kB of download and needs a server that advertises the grants, so
* it is a flag rather than an assumption.
*/
val downstream: Boolean = true,
)
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 tests = ArrayList<Test>()
val allFindings = ArrayList<Finding>()
// One ProbeSession for the whole run. A second one would open a new socket and restart
// the sequence counter, which the server's anti-replay window correctly rejects — so the
// re-primed source is never recorded and every granted send goes to the old, closed port.
// Session identity lives on the server; the socket must live as long as it does.
ProbeSession(cfg.credential, session, cfg.udpHost, cfg.udpPort).use { ps ->
val (test, findings) = echoTrain(cfg, ps, startMono, control, session.sessionId)
tests.add(test)
allFindings.addAll(findings)
// Downstream needs a session the server has already seen traffic from — the echo
// train just provided that — and a server that advertises the grants. Skipped
// quietly against an older server rather than reported as a failure of the network.
if (cfg.downstream && profile.supports("downtrain") && profile.supports("big-send")) {
val (dsTests, dsFindings) = DownstreamMeasurement(ids)
.run(cfg.credential, session.sessionId, control, ps, sessionRef = "sess-1")
tests.addAll(dsTests)
allFindings.addAll(dsFindings)
}
}
control.deleteSession(cfg.credential, session.sessionId)
val summary = Verdicts.derive(tests, allFindings)
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 = tests,
findings = allFindings,
summary = summary,
)
}
private fun echoTrain(
cfg: Config, ps: ProbeSession, startMono: Long,
control: ControlClient? = null, sessionId: String? = null,
): 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>()
// Wire sequence numbers, kept so the server's observations can be correlated packet by
// packet. They are not 0..n-1: the counter is shared with every other packet type on the
// session, so "the nth echo" is not "sequence n".
val wireSeqs = ArrayList<Int>()
for (i in 0 until cfg.echoCount) {
val txMono = ids.monoNs() - startMono
val r = ps.echo(cfg.echoPaddingBytes)
wireSeqs.add(ps.lastSeq)
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)
}
}
// Ask the server what it actually received. This is what turns "3 % loss somewhere" into
// "3 % loss upstream" - the least useful form of the answer into a usable one.
val directional: DirectionalMetrics? =
if (control != null && sessionId != null) {
runCatching {
val samples = wireSeqs.indices.map {
Directional.Sample(wireSeqs[it], tTx[it] ?: 0L, tRx[it])
}
Directional.analyse(samples, serverSightings(control, cfg, sessionId))
}.getOrNull() // an older server without the endpoint simply yields no split
} else {
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 directionalJson = directional?.let {
json.encodeToJsonElement(DirectionalMetrics.serializer(), it) as JsonObject
}
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,
)
).let { base -> JsonObject((base as JsonObject) + (directionalJson ?: JsonObject(emptyMap()))) }
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."))
}
// Naming the direction is the entire value of the split, so the findings do.
directional?.let { d ->
when {
d.noneReachedServer && received == 0 -> findings.add(
finding("nat.udp_unreachable_upstream", Category.CONNECTIVITY, Severity.HIGH, testId,
"Nothing reached the server",
"The server received none of the ${d.sent} probes, so the traffic is being " +
"dropped on the way out, not on the way back. A firewall or NAT on " +
"this side of the path is the place to look."),
)
d.lossUpstreamPct >= 2.0 -> findings.add(
finding("connectivity.loss_upstream", Category.CONNECTIVITY, Severity.MEDIUM, testId,
"${d.lossUpstreamPct} % of probes were lost on the way to the server",
"${d.lostUpstream} of ${d.sent} probes never reached the server. The " +
"return path is not implicated: replies came back for everything that " +
"arrived."),
)
}
if (d.lossDownstreamPct >= 2.0) {
findings.add(
finding("connectivity.loss_downstream", Category.CONNECTIVITY, Severity.MEDIUM, testId,
"${d.lossDownstreamPct} % of replies were lost on the way back",
"The server received ${d.seenByServer} probes and answered them, but " +
"${d.lostDownstream} of those replies never arrived. The outbound path " +
"is fine; the fault is on the return leg."),
)
}
}
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
}
/**
* The server's per-packet record of this session's echoes (spec section 6). Filtered to
* ECHO_REQ, because the observation list also holds MTU probes and anything else we sent -
* counting those as train packets would invent loss that is not there.
*/
private fun serverSightings(
control: ControlClient, cfg: Config, sessionId: String,
): List<Directional.ServerSighting> {
val body = control.observations(cfg.credential, sessionId)
val packets = Json.parseToJsonElement(body).jsonObject["udp"]
?.jsonObject?.get("packets") as? JsonArray ?: return emptyList()
return packets.mapNotNull { el ->
val o = el as? JsonObject ?: return@mapNotNull null
val type = o["type"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null
if (type != ECHO_REQ_TYPE) return@mapNotNull null
Directional.ServerSighting(
seq = o["seq"]?.jsonPrimitive?.intOrNull ?: return@mapNotNull null,
tRxNs = o["t_rx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
tTxNs = o["t_tx_ns"]?.jsonPrimitive?.longOrNull ?: return@mapNotNull null,
)
}
}
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
const val ECHO_REQ_TYPE = 0x01
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,155 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.engine.Directional.Sample
import app.echo_lot.engine.Directional.ServerSighting
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The arithmetic that turns "3 % loss somewhere" into "3 % loss upstream". Getting a denominator
* wrong here does not crash anything — it produces a plausible number pointing at the wrong half
* of the network, which is worse than no number at all. Hence a test per claim.
*/
class DirectionalTest {
/** A clean train: every packet sent, seen and answered. Server clock offset by a constant. */
private fun clean(n: Int, offsetNs: Long = 5_000_000_000L): Pair<List<Sample>, List<ServerSighting>> {
val sent = (1..n).map { Sample(it, tTxNs = it * 10_000_000L, tRxNs = it * 10_000_000L + 4_000_000L) }
val seen = (1..n).map {
ServerSighting(it, tRxNs = offsetNs + it * 10_000_000L + 2_000_000L,
tTxNs = offsetNs + it * 10_000_000L + 2_100_000L)
}
return sent to seen
}
@Test
fun aCleanTrainReportsNoLossInEitherDirection() {
val (sent, seen) = clean(10)
val m = Directional.analyse(sent, seen)
assertEquals(10, m.sent)
assertEquals(10, m.seenByServer)
assertEquals(10, m.repliesReceived)
assertEquals(0.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct)
assertFalse(m.noneReachedServer)
}
// The whole point: a packet the server never saw was lost on the way there.
@Test
fun packetsTheServerNeverSawAreUpstreamLoss() {
val (sent, seen) = clean(10)
val m = Directional.analyse(sent, seen.filter { it.seq !in setOf(3, 7) })
assertEquals(2, m.lostUpstream)
assertEquals(0, m.lostDownstream)
assertEquals(20.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct, "a packet that never arrived cannot be lost coming back")
}
@Test
fun repliesThatNeverArrivedAreDownstreamLoss() {
val (sent, seen) = clean(10)
val withHoles = sent.map { if (it.seq in setOf(2, 5)) it.copy(tRxNs = null) else it }
val m = Directional.analyse(withHoles, seen)
assertEquals(0, m.lostUpstream)
assertEquals(2, m.lostDownstream)
assertEquals(20.0, m.lossDownstreamPct)
}
// Downstream loss is measured against what actually reached the server. Using "sent" as the
// denominator would count every upstream loss a second time and overstate the return path.
@Test
fun downstreamLossIsRelativeToWhatReachedTheServer() {
val (sent, seen) = clean(10)
// 5 lost on the way there; of the 5 that arrived, 1 reply is lost coming back.
val seenPartial = seen.filter { it.seq > 5 }
val withHole = sent.map {
when {
it.seq <= 5 -> it.copy(tRxNs = null) // never got there, so never came back
it.seq == 6 -> it.copy(tRxNs = null) // arrived, reply lost
else -> it
}
}
val m = Directional.analyse(withHole, seenPartial)
assertEquals(5, m.lostUpstream)
assertEquals(50.0, m.lossUpstreamPct)
assertEquals(1, m.lostDownstream)
assertEquals(20.0, m.lossDownstreamPct, "1 of the 5 that arrived, not 1 of 10")
}
@Test
fun aServerThatSawNothingIsCalledOutSeparately() {
val (sent, _) = clean(6)
val m = Directional.analyse(sent.map { it.copy(tRxNs = null) }, emptyList())
assertTrue(m.noneReachedServer)
assertEquals(100.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct, "with nothing arriving there is no return path to blame")
}
// Jitter is legitimate without synchronised clocks because the offset cancels when successive
// one-way samples are differenced. This pins that: a huge constant offset must not show up.
@Test
fun jitterIsUnaffectedByTheClockOffsetBetweenTheTwoMachines() {
val (sent, near) = clean(10, offsetNs = 0)
val (_, far) = clean(10, offsetNs = 9_999_999_999L)
val a = Directional.analyse(sent, near)
val b = Directional.analyse(sent, far)
assertEquals(a.jitterUpstreamMs, b.jitterUpstreamMs,
"a constant clock offset must cancel when consecutive samples are differenced")
assertEquals(0.0, assertNotNull(a.jitterUpstreamMs), "an evenly spaced train has no jitter")
}
@Test
fun jitterReflectsUnevenArrival() {
val sent = listOf(
Sample(1, 0, 10_000_000),
Sample(2, 10_000_000, 20_000_000),
Sample(3, 20_000_000, 30_000_000),
)
// Server receive times drift: +2ms, +7ms, +3ms relative to send.
val seen = listOf(
ServerSighting(1, 2_000_000, 2_100_000),
ServerSighting(2, 17_000_000, 17_100_000),
ServerSighting(3, 23_000_000, 23_100_000),
)
val m = Directional.analyse(sent, seen)
// one-way samples: 2ms, 7ms, 3ms → |7-2| and |3-7| → mean 4.5ms
assertEquals(4.5, assertNotNull(m.jitterUpstreamMs))
}
// "No jitter" and "not enough data to say" are different claims, and only one is true here.
@Test
fun tooFewSamplesReportsNoJitterRatherThanZero() {
val m = Directional.analyse(
listOf(Sample(1, 0, 10_000_000)),
listOf(ServerSighting(1, 2_000_000, 2_100_000)),
)
assertNull(m.jitterUpstreamMs)
assertNull(m.jitterDownstreamMs)
}
// A server record for a sequence we never sent is not evidence about this train; folding it
// in would yield loss percentages outside 0100.
@Test
fun strayServerRecordsAreIgnored() {
val (sent, seen) = clean(5)
val m = Directional.analyse(sent, seen + ServerSighting(99, 1, 2) + ServerSighting(100, 3, 4))
assertEquals(5, m.seenByServer)
assertEquals(0.0, m.lossUpstreamPct)
assertTrue(m.lossDownstreamPct in 0.0..100.0)
}
@Test
fun anEmptyTrainDoesNotDivideByZero() {
val m = Directional.analyse(emptyList(), emptyList())
assertEquals(0.0, m.lossUpstreamPct)
assertEquals(0.0, m.lossDownstreamPct)
assertFalse(m.noneReachedServer, "nothing sent is not the same as nothing arriving")
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.Compat
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.VersionRefused
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Checks the version gate against a LIVE server — the half that unit tests cannot reach, because
* the whole point is that two independently-built artifacts agree. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveCompatTest {
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 fun clientAs(version: String) = ControlClient(url!!, setOf(pin!!), version)
@Test
fun theServerAdvertisesAndEnforcesItsWindow() {
if (url == null || pin == null || cred == null) {
println("LiveCompatTest skipped (no ECHOLOT_LIVE_* env)"); return
}
// The profile must state the window — without it the app cannot pre-empt a refusal.
val profile = clientAs("0.2.0").profile(cred)
println("server ${profile.serverVersion} protocol=${profile.compat.protocolVersion} " +
"accepts app [${profile.compat.appMin}, ${profile.compat.appMax})")
assertTrue(profile.compat.protocolVersion.isNotBlank(), "profile omits protocol_version")
assertTrue(profile.compat.appMin.isNotBlank(), "profile omits app_min")
// This build must be inside it, or every other live test here is meaningless.
val verdict = Compat.check(profile, "0.2.0")
assertEquals(Compat.Verdict.OK, verdict.verdict, verdict.message ?: "")
// The profile stays reachable for a version the server would otherwise refuse: that is
// how a refused client discovers what it needs.
val ancient = clientAs("0.1.0")
val stillReadable = ancient.profile(cred)
assertEquals(profile.serverVersion, stillReadable.serverVersion,
"the profile endpoint must never be gated on app version")
// And a gated endpoint refuses it, with a message naming the window.
try {
ancient.createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an out-of-window app")
} catch (e: VersionRefused) {
val msg = assertNotNull(e.message)
println("refused as expected: $msg")
assertTrue(msg.contains("0.1.0"), "refusal should name the offending version: $msg")
assertTrue(msg.contains(profile.compat.appMin), "refusal should name the window: $msg")
}
// Too new is refused the same way — the window is a range, not a floor.
try {
clientAs("99.0.0").createSession(cred, System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr")
fail("server accepted a session from an app above its window")
} catch (e: VersionRefused) {
println("too-new refused as expected: ${e.message}")
}
}
}
@@ -0,0 +1,68 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* Runs DownstreamMeasurement against a LIVE server and checks the *documents* it produces, not
* just that packets moved: the tests must carry recomputable metrics and land on the right test
* types, because that is what an archived run is read back as. Self-skips without ECHOLOT_LIVE_*.
*/
class LiveDownstreamTest {
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"
@JTest
fun producesDownstreamTestsAndFindings() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveDownstreamTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
val (tests, findings) = ProbeSession(cred, session, host, port).use { ps ->
ps.echo() // prime: the grant binds to the source the server has actually observed
DownstreamMeasurement(SystemIdSource())
.run(cred, session.sessionId, control, ps, sessionRef = "sess-1")
}
control.deleteSession(cred, session.sessionId)
for (t in tests) println("${t.type} status=${t.status} metrics=${t.metrics}")
for (f in findings) println("finding ${f.code} [${f.severity}] ${f.title}")
assertEquals(3, tests.size, "expected pmtud_down, frag_delivery and a downstream train")
val byType = tests.associateBy { it.type }
val pmtud = assertNotNull(byType[TestType.MTU_PMTUD_DOWN], "no mtu.pmtud_down test")
assertTrue(pmtud.status == TestStatus.OK || pmtud.status == TestStatus.PARTIAL,
"DF probe did not deliver anything: ${pmtud.status}")
val pathMtu = pmtud.metrics?.get("path_mtu_bytes")?.toString()?.toIntOrNull()
assertNotNull(pathMtu, "pmtud_down must report a path MTU")
assertTrue(pathMtu in 576..9000, "implausible downstream path MTU: $pathMtu")
println("downstream path MTU = $pathMtu bytes")
val frag = assertNotNull(byType[TestType.MTU_FRAG_DELIVERY], "no mtu.frag_delivery test")
assertNotNull(frag.metrics?.get("largest_delivered_bytes"))
val train = assertNotNull(byType[TestType.TRAIN_UDP_DOWNSTREAM], "no downstream train")
assertNotNull(train.evidence, "a train without columnar evidence is not recomputable")
val received = train.metrics?.get("received")?.toString()?.toIntOrNull() ?: 0
assertTrue(received > 0, "no downstream train packets arrived")
println("downstream train: $received received, loss=${train.metrics?.get("loss_pct")}, " +
"reordered=${train.metrics?.get("reordered_packets")}")
}
}
@@ -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.protocol.EnrollmentLink
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.test.fail
/**
* Enrolls against a LIVE server using the link the server itself minted (probe-protocol.md §2.1).
*
* This is the test that matters for enrollment, because the failure mode it guards against is a
* *disagreement* between two programs: the Go side assembles the link, the Kotlin side takes it
* apart, and if they differ by one percent-encoding the pin is wrong by one character — which
* does not fail loudly, it fails as an inscrutable TLS error days later. A unit test on either
* side alone cannot see that.
*
* Needs ECHOLOT_ENROLL_URI (minted over SSH by scripts/test-fmr.sh); self-skips without it.
*/
class LiveEnrollmentTest {
private val enrollUri = System.getenv("ECHOLOT_ENROLL_URI")
@Test
fun enrollsFromTheServersOwnLink() {
if (enrollUri.isNullOrBlank()) {
println("LiveEnrollmentTest skipped (no ECHOLOT_ENROLL_URI)"); return
}
println("link: ${enrollUri.take(60)}")
val link = assertNotNull(
EnrollmentLink.parse(enrollUri),
"the client could not parse a link the server produced — the two sides disagree",
)
println("parsed: url=${link.controlUrl} pin=${link.pin.take(12)}… token=${link.token.take(8)}")
// Redeeming applies the pin to the very request that spends the token, so a wrong pin
// fails here at the handshake rather than after the token is gone.
val enrolled = link.redeem(deviceName = "live-test", appVersion = "0.2.0")
assertTrue(enrolled.credential.isNotBlank(), "no credential came back")
assertTrue(enrolled.deviceId.isNotBlank(), "no device id came back")
println("enrolled: device=${enrolled.deviceId} server=${enrolled.profile.name} " +
"${enrolled.profile.serverVersion}")
// The credential must actually work, and the pin from the link must be the one that
// verifies the server — that is the whole claim the link is making.
assertEquals(link.controlUrl, enrolled.controlUrl)
assertTrue(enrolled.profile.capabilities.contains("udp-probe"),
"profile fetched with the new credential looks wrong: ${enrolled.profile.capabilities}")
// Single-use: a token that still works after redemption is a token an attacker can reuse.
try {
link.redeem(deviceName = "should-not-happen", appVersion = "0.2.0")
fail("the enrollment token was accepted twice — it must be single-use")
} catch (t: Throwable) {
println("second redemption correctly refused: ${t.message?.take(120)}")
}
}
}
@@ -0,0 +1,89 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.ProbeSession
import app.echo_lot.protocol.Wire
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Exercises the server's §5 granted sends against a LIVE server: downtrain (downstream loss /
* ordering) and big_send (downstream MTU). Self-skips without ECHOLOT_LIVE_*.
*
* This is the direction a client cannot measure alone — only the far end can push large or
* numerous packets toward it — so it is also the direction that needs the anti-amplification
* grant, and this test is the proof that the grant path works end to end.
*/
class LiveGrantedTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val udp = System.getenv("ECHOLOT_LIVE_UDP")
private val target = System.getenv("ECHOLOT_LIVE_TARGET") ?: "fmr"
@Test
fun downstreamTrainAndBigSend() {
if (url == null || pin == null || cred == null || udp == null) {
println("LiveGrantedTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
println("capabilities: ${profile.capabilities}")
val session = control.createSession(cred, target)
val (host, port) = udp.split(":").let { it[0] to it[1].toInt() }
ProbeSession(cred, session, host, port).use { ps ->
// The grant is bound to the OBSERVED data-plane source, so we must be seen first.
val echo = ps.echo()
println("primed with echo rtt=${echo?.rttMs}")
// --- downtrain: 50 packets of 300 bytes, 5ms apart ---
val dtResp = control.action(
cred, session.sessionId,
"""{"action":"downtrain","count":50,"size_bytes":300,"interval_us":5000}""",
)
println("downtrain accepted: ${dtResp.take(160)}")
val down = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_DOWNTRAIN_DATA }
val seqs = down.map { it.seq }.toSet()
println("downtrain received ${down.size}/50 packets, distinct seqs=${seqs.size}, " +
"sizes=${down.map { it.sizeBytes }.distinct()}")
assertTrue(down.isNotEmpty(), "no DOWNTRAIN_DATA arrived — granted send path is broken")
// --- big_send with DF: the largest size that arrives is the downstream path MTU ---
val sizes = listOf(600, 1200, 1400, 1472, 1500, 2000, 4000)
val dfResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":true,"sizes_bytes":${sizes}}""",
)
println("big_send(df) accepted: ${dfResp.take(200)}")
val dfArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(df) arrived: $dfArrived")
assertTrue(dfArrived.isNotEmpty(), "no unfragmented BIG_SEND packets arrived")
val pathMtu = dfArrived.max()
// --- and without DF, to see whether fragments get through above that ---
val fragResp = control.action(
cred, session.sessionId,
"""{"action":"big_send","df":false,"sizes_bytes":${sizes}}""",
)
println("big_send(frag) accepted: ${fragResp.take(200)}")
val fragArrived = ps.collectGranted(windowMs = 4000)
.filter { it.type == Wire.TYPE_BIG_SEND }.map { it.sizeBytes }.sorted()
println("big_send(frag) arrived: $fragArrived")
// The distinction the DF flag exists for: fragmented delivery may exceed the
// unfragmented path MTU, and reporting the former as the latter would be a lie.
println("downstream path MTU (payload bytes) = $pathMtu; " +
"largest fragmented delivery = ${fragArrived.maxOrNull()}")
assertTrue((fragArrived.maxOrNull() ?: 0) >= pathMtu,
"fragmented delivery should reach at least as far as unfragmented")
}
control.deleteSession(cred, session.sessionId)
}
}
@@ -0,0 +1,79 @@
// 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.assertNotNull
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"))
// A full run is the echo train plus the three downstream tests; assert on the one this
// test is about rather than on the count, so adding a measurement is not a test edit.
for (t in doc.tests) println(" ${t.type}${t.status}")
for (f in doc.findings) println(" finding ${f.code} [${f.severity}] ${f.title}")
val test = doc.tests.first { it.type == TestType.TRAIN_UDP_UPDOWN }
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"))
// The directional split is the point of asking the server what it saw: without it a
// lossy path is reported as "loss" with no direction, which sends an engineer looking
// in both at once. Correlation is by wire sequence number, so a mismatch here means the
// two sides disagree about which packet is which.
val m = metrics.toString()
assertTrue(m.contains("seen_by_server"), "no directional split in the metrics: $m")
val seen = Regex(""""seen_by_server":(\d+)""").find(m)?.groupValues?.get(1)?.toInt()
assertNotNull(seen, "seen_by_server missing")
assertEquals(20, seen, "the server should have seen every probe on a healthy path")
assertTrue(m.contains("jitter_upstream_ms"), "no per-direction jitter: $m")
println("directional: $m")
assertTrue(doc.summary != null)
// A healthy local->fmr path should be green (no loss, no rebinding) or yellow.
println("summary: ${doc.summary}")
}
}
@@ -0,0 +1,102 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.engine
import app.echo_lot.privacy.Anonymizer
import app.echo_lot.privacy.PrivacyLevel
import app.echo_lot.privacy.Salt
import app.echo_lot.protocol.ControlClient
import app.echo_lot.protocol.UploadRefused
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Drives the upload path against a LIVE server: anonymize, upload, list, fetch back, delete.
*
* The point is not that the HTTP works — it is that what comes *back off the server* has been
* stripped. Uploading and then re-reading the stored document is the only check that proves the
* anonymizer ran on the bytes that actually left, rather than on a copy. Self-skips without
* ECHOLOT_LIVE_*.
*/
class LiveUploadTest {
private val url = System.getenv("ECHOLOT_LIVE_URL")
private val pin = System.getenv("ECHOLOT_LIVE_PIN")
private val cred = System.getenv("ECHOLOT_LIVE_CRED")
private val json = Json { prettyPrint = false }
private fun sampleRun(id: String) = """
{
"schema": "echolot/measurement",
"run": {
"id": "$id", "trigger": "manual", "started_at": "2026-08-01T10:00:00Z",
"notes": "kitchen table",
"device": {"manufacturer": "OnePlus", "model": "CPH2747"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "203.0.113.77",
"ssdp_responders": [{"friendly_name": "Living Room TV"}]
}],
"tests": [{"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0}}],
"findings": [{"id": "f1", "code": "nat.udp_rebinding", "severity": "medium"}],
"summary": {"verdict": "warn"}
}
""".trimIndent()
@Test
fun uploadRoundTrip() {
if (url == null || pin == null || cred == null) {
println("LiveUploadTest skipped (no ECHOLOT_LIVE_* env)"); return
}
val control = ControlClient(url, setOf(pin))
val profile = control.profile(cred)
val policy = profile.uploads
println("upload policy: mode=${policy.mode} min_anon=${policy.minAnonymization} " +
"max_bytes=${policy.maxBytes} retention_days=${policy.retentionDays}")
val runId = "livetest-" + System.nanoTime().toString().takeLast(10)
val level = PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.fromWire(policy.minAnonymization))
val redacted = json.encodeToString(
JsonObject.serializer(),
Anonymizer(level, Salt.perRun(ByteArray(32) { 9 }))
.anonymize(json.parseToJsonElement(sampleRun(runId)).jsonObject),
)
assertFalse(redacted.contains("Rambossek"), "the anonymizer did not strip the SSID before upload")
if (!policy.accepted) {
// A server configured to refuse must refuse — that is the behaviour worth asserting.
try {
control.uploadRun(cred, redacted)
throw AssertionError("server advertises mode=${policy.mode} but accepted an upload")
} catch (e: UploadRefused) {
println("upload correctly refused: ${e.message?.take(140)}")
return
}
}
val created = control.uploadRun(cred, redacted)
println("stored: ${created.take(200)}")
val listed = control.listRuns(cred)
assertTrue(listed.contains(runId), "uploaded run is missing from the server's list")
val fetched = control.getRun(cred, runId)
assertFalse(fetched.contains("Rambossek"), "the SSID is sitting on the server")
assertFalse(fetched.contains("Living Room TV"), "an SSDP neighbour name is sitting on the server")
assertFalse(fetched.contains("kitchen table"), "a free-text note is sitting on the server")
assertTrue(fetched.contains("nat.udp_rebinding"), "the finding code should survive — it is the point")
assertTrue(fetched.contains("12.4"), "metrics should survive anonymization")
println("round trip verified: identifiers stripped, measurements intact")
control.deleteRun(cred, runId)
assertFalse(control.listRuns(cred).contains(runId), "delete did not remove the run")
println("deleted")
}
}
@@ -0,0 +1,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,153 @@
// 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"
/** Server-to-client train under a §3.4 grant: the direction a round trip cannot separate. */
const val TRAIN_UDP_DOWNSTREAM = "train.udp_downstream"
// 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"
/** Whether fragments survive arriving out of order, not merely whether they survive. */
const val MTU_FRAG_ORDERING = "mtu.frag_ordering"
// nat
const val NAT_STUN_5780 = "nat.stun_5780"
const val NAT_MAPPING_LIFETIME_UDP = "nat.mapping_lifetime_udp"
const val NAT_MAPPING_LIFETIME_TCP = "nat.mapping_lifetime_tcp"
const val NAT_HAIRPIN = "nat.hairpin"
const val NAT_CONNECT_BACK = "nat.connect_back"
const val NAT_CGNAT_DETECT = "nat.cgnat_detect"
// dns
const val DNS_RESOLVER_INVENTORY = "dns.resolver_inventory"
const val DNS_CANARY = "dns.canary"
const val DNS_INTERCEPTION = "dns.interception"
const val DNS_TTL_INTEGRITY = "dns.ttl_integrity"
const val DNS_ANSWER_INTEGRITY = "dns.answer_integrity"
const val DNS_DNSSEC = "dns.dnssec"
const val DNS_NXDOMAIN_WILDCARD = "dns.nxdomain_wildcard"
const val DNS_REBIND_FILTER = "dns.rebind_filter"
const val DNS_AAAA_FILTER = "dns.aaaa_filter"
const val DNS_DNS64 = "dns.dns64"
const val DNS_COMPARE = "dns.compare"
// sec
const val SEC_TLS_REFERENCE = "sec.tls_reference"
const val SEC_CLIENTHELLO_ECHO = "sec.clienthello_echo"
const val SEC_HTTP_ECHO = "sec.http_echo"
const val SEC_SNI_FILTER = "sec.sni_filter"
const val SEC_DSCP_ECN_SURVIVAL = "sec.dscp_ecn_survival"
const val SEC_ARP_WATCH = "sec.arp_watch"
// port
const val PORT_REACH_SWEEP = "port.reach_sweep"
const val PORT_UDP_USABILITY = "port.udp_usability"
// perf
const val PERF_THROUGHPUT_TCP = "perf.throughput_tcp"
const val PERF_THROUGHPUT_UDP = "perf.throughput_udp"
const val PERF_BUFFERBLOAT = "perf.bufferbloat"
const val PERF_RRC_LATENCY = "perf.rrc_latency"
// v6
const val V6_DUALSTACK_COMPARE = "v6.dualstack_compare"
const val V6_HAPPY_EYEBALLS = "v6.happy_eyeballs"
const val V6_BROKENNESS = "v6.brokenness"
const val V6_NAT64_CLAT = "v6.nat64_clat"
// wifi
const val WIFI_ENVIRONMENT_SCAN = "wifi.environment_scan"
const val WIFI_ROAM_LOG = "wifi.roam_log"
const val WIFI_SIGNAL_LOG = "wifi.signal_log"
// local
const val LOCAL_MDNS_INVENTORY = "local.mdns_inventory"
const val LOCAL_SSDP_INVENTORY = "local.ssdp_inventory"
const val LOCAL_LLMNR_INVENTORY = "local.llmnr_inventory"
const val LOCAL_GATEWAY_SERVICES = "local.gateway_services"
const val LOCAL_NTP = "local.ntp"
// peer
const val PEER_REACHABILITY = "peer.reachability"
const val PEER_ISOLATION = "peer.isolation"
const val PEER_MULTICAST = "peer.multicast"
const val PEER_LAN_TRAIN = "peer.lan_train"
const val PEER_LEASE_DIFF = "peer.lease_diff"
// time
const val TIME_SERVER_OFFSET = "time.server_offset"
/** Maps a dotted test type to its §7.2 category for verdict rollup. */
fun category(type: String): Category = when (type.substringBefore('.')) {
"link", "icmp", "trace", "traceroute", "train", "port", "time", "net" -> Category.CONNECTIVITY
"dns" -> Category.DNS
"nat" -> Category.NAT
"mtu" -> Category.MTU
"v6" -> Category.IPV6
"sec" -> Category.SECURITY
"perf" -> Category.PERFORMANCE
"local", "peer" -> Category.LOCAL
"wifi" -> Category.WIFI
else -> Category.CONNECTIVITY
}
}
@@ -0,0 +1,71 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlinx.serialization.json.Json
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SerializationTest {
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@JTest
fun documentRoundTrips() {
val doc = MeasurementDocument(
run = Run(
id = "0198c5f2-0000-7000-8000-000000000000",
trigger = Trigger.MANUAL,
startedAt = "2026-07-31T14:03:21.114Z",
clock = Clock(monoOriginWall = "2026-07-31T14:03:21.114Z"),
app = AppInfo(version = "0.1.0", build = 1),
device = DeviceInfo("OnePlus", "CPH2747", 36, "16"),
tiers = Tiers(app = true, shizuku = true),
),
networks = listOf(
Network(
id = "net-1", transport = Transport.WIFI, iface = "wlan0",
link = Link(mtu = 1500, addresses = listOf(Address("192.0.2.23", 24, "global"))),
wifi = Wifi(ssid = "example", rssiDbm = -54),
),
),
tests = listOf(
Test(
id = "t-1", type = TestType.ICMP_PING4, networkRef = "net-1", tier = Tier.APP,
startedMonoNs = 0, endedMonoNs = 38_000_000, status = TestStatus.OK,
evidence = TrainEvidence(
epochMonoNs = 0, seq = listOf(0, 1), tTxNs = listOf(0L, 20_000_000L),
tRxNs = listOf(16_500_000L, null), sizeBytes = listOf(64, 64),
).toEvidence(),
),
),
)
val encoded = json.encodeToString(MeasurementDocument.serializer(), doc)
val decoded = json.decodeFromString(MeasurementDocument.serializer(), encoded)
assertEquals(doc.run.id, decoded.run.id)
assertEquals(Transport.WIFI, decoded.networks[0].transport)
assertEquals(TestType.ICMP_PING4, decoded.tests[0].type)
// snake_case field names on the wire
assertTrue(encoded.contains("\"schema_version\""))
assertTrue(encoded.contains("\"mono_origin_wall\""))
assertTrue(encoded.contains("\"t_tx_ns\""))
// null preserved at train index 1
assertTrue(encoded.contains("[16500000,null]"))
}
@JTest
fun findingRequiresEvidence() {
try {
Finding(
id = "f-1", code = "x", category = Category.DNS, severity = Severity.INFO,
confidence = Confidence.LOW, title = "t", description = "d", evidenceRefs = emptyList(),
)
throw AssertionError("expected IllegalArgumentException for empty evidence_refs")
} catch (e: IllegalArgumentException) {
// expected — a finding with no evidence is invalid (§7.1)
}
}
}
@@ -0,0 +1,109 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.measurement
import kotlin.test.Test as JTest
import kotlin.test.assertEquals
class VerdictsTest {
private fun test(type: String, status: TestStatus, id: String = type): Test =
Test(id = id, type = type, tier = Tier.APP, startedMonoNs = 0, endedMonoNs = 1, status = status)
private fun finding(cat: Category, sev: Severity, id: String = "f-$cat-$sev"): Finding =
Finding(
id = id, code = "x.$cat", category = cat, severity = sev, confidence = Confidence.HIGH,
title = "t", description = "d", evidenceRefs = listOf(EvidenceRef("some-test")),
)
@JTest
fun categoryLightFromWorstSeverity() {
val tests = listOf(test(TestType.DNS_CANARY, TestStatus.OK))
val findings = listOf(
finding(Category.DNS, Severity.LOW),
finding(Category.DNS, Severity.HIGH), // worst → red
finding(Category.DNS, Severity.INFO),
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.categories["dns"]!!.verdict)
assertEquals("f-DNS-HIGH", s.categories["dns"]!!.worstFinding)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun noFindingsIsGreen() {
val s = Verdicts.derive(listOf(test(TestType.MTU_BLACKHOLE, TestStatus.OK)), emptyList())
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall)
}
@JTest
fun mediumAndLowAreYellow() {
val s = Verdicts.derive(
listOf(test(TestType.SEC_HTTP_ECHO, TestStatus.OK)),
listOf(finding(Category.SECURITY, Severity.MEDIUM)),
)
assertEquals(Verdict.YELLOW, s.categories["security"]!!.verdict)
}
@JTest
fun majorityFailedIsInconclusive() {
// 2 of 3 dns tests failed → > 50% → inconclusive, even with a finding present.
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "a"),
test(TestType.DNS_TTL_INTEGRITY, TestStatus.UNSUPPORTED, "b"),
test(TestType.DNS_COMPARE, TestStatus.OK, "c"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.DNS, Severity.HIGH)))
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(2, s.categories["dns"]!!.testsFailed)
assertEquals(3, s.categories["dns"]!!.testsRun)
}
@JTest
fun exactlyHalfFailedIsNotInconclusive() {
// 1 of 2 failed → not > 50% → the finding decides.
val tests = listOf(
test(TestType.NAT_HAIRPIN, TestStatus.FAILED, "a"),
test(TestType.NAT_CONNECT_BACK, TestStatus.OK, "b"),
)
val s = Verdicts.derive(tests, listOf(finding(Category.NAT, Severity.CRITICAL)))
assertEquals(Verdict.RED, s.categories["nat"]!!.verdict)
}
@JTest
fun overallIsWorstCategory() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.OK, "d"),
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"),
)
val findings = listOf(
finding(Category.DNS, Severity.MEDIUM), // yellow
finding(Category.MTU, Severity.CRITICAL), // red
)
val s = Verdicts.derive(tests, findings)
assertEquals(Verdict.RED, s.overall)
}
@JTest
fun overallInconclusiveOnlyWhenAllAre() {
val tests = listOf(
test(TestType.DNS_CANARY, TestStatus.FAILED, "d"), // dns inconclusive
test(TestType.MTU_BLACKHOLE, TestStatus.OK, "m"), // mtu green
)
val s = Verdicts.derive(tests, emptyList())
assertEquals(Verdict.INCONCLUSIVE, s.categories["dns"]!!.verdict)
assertEquals(Verdict.GREEN, s.categories["mtu"]!!.verdict)
assertEquals(Verdict.GREEN, s.overall) // not all inconclusive → mtu decides
}
@JTest
fun categoryMappingCoversFamilies() {
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TRACEROUTE_UDP4))
assertEquals(Category.IPV6, TestType.category(TestType.V6_BROKENNESS))
assertEquals(Category.LOCAL, TestType.category(TestType.PEER_MULTICAST))
assertEquals(Category.PERFORMANCE, TestType.category(TestType.PERF_BUFFERBLOAT))
assertEquals(Category.CONNECTIVITY, TestType.category(TestType.TIME_SERVER_OFFSET))
}
}
+23
View File
@@ -0,0 +1,23 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// The anonymizer (measurement-schema.md §8). Pure Kotlin/JVM and deliberately
// dependency-free beyond JSON: it must be trivially auditable, because a bug
// here leaks a user's network onto someone else's server.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
jvmToolchain(21)
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
tasks.test { useJUnitPlatform() }
@@ -0,0 +1,237 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package privacy implements the anonymization contract of measurement-schema.md §8.
//
// The threat model is specific. An engineer running their own server wants the full document —
// SSIDs and MACs are what make a run useful a week later. Someone measuring against a stranger's
// server wants the numbers to survive and the identifiers not to. So this is a *transform*, not a
// filter: the output is still a valid measurement document with the same tests, metrics and
// findings; only the identifying scalars change, and consistently, so "same SSID as last run" is
// still answerable from pseudonyms alone.
//
// Two properties are load-bearing and are what the tests pin:
// - Consistency within a document: one input value always maps to one pseudonym, so
// correlations inside a run survive.
// - No consistency *across* documents unless the user asks for it: the salt is per-run by
// default, so pseudonyms cannot be used to track a device between uploads. A stable salt is
// opt-in (`Salt.stable`) for people diffing their own history on their own server.
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import java.security.MessageDigest
import java.util.Locale
/** How much to strip. Ordered: FULL < BALANCED < STRICT. Wire values match the server's. */
enum class PrivacyLevel(val wire: String) {
/** Nothing removed. The right choice for your own server. */
FULL("full"),
/**
* Identifiers pseudonymized, neighbour inventory dropped. Topology and timing survive:
* you can still see that the gateway is a MikroTik at a /24 boundary with 3 % loss, but not
* which MikroTik, on which SSID, next to whose Chromecast.
*/
BALANCED("balanced"),
/**
* Numbers only: tests keep their metrics and status, evidence is dropped, findings keep their
* codes and severities but lose descriptions (which quote real names). What is left cannot
* identify a network, and is still enough for aggregate "how common is this fault" work.
*/
STRICT("strict");
companion object {
fun fromWire(s: String?): PrivacyLevel =
entries.firstOrNull { it.wire == s?.lowercase(Locale.ROOT) } ?: FULL
/** The stricter of two levels — used to honour a server's minimum. */
fun max(a: PrivacyLevel, b: PrivacyLevel): PrivacyLevel = if (a.ordinal >= b.ordinal) a else b
}
}
/**
* The pseudonymization salt. Per-run by default: a fresh random salt means the same SSID uploaded
* twice yields two different pseudonyms, so an upload endpoint cannot link runs to a device.
* A stable salt trades that away for cross-run diffing and is only appropriate on a server you
* own — the app makes that an explicit choice, not a default.
*/
class Salt private constructor(internal val bytes: ByteArray, val stable: Boolean) {
companion object {
fun perRun(random: ByteArray): Salt = Salt(random.copyOf(), stable = false)
fun stable(secret: ByteArray): Salt = Salt(secret.copyOf(), stable = true)
}
}
/**
* Transforms a measurement document to [level].
*
* Field classification is by JSON key name, because the schema names things consistently
* (`ssid`, `bssid`, `mac`, `ip4`, `ip6`, `fqdn`, …) and a name-driven pass is auditable by
* reading one table. Anything unrecognized is treated as identifying when it is a string inside
* a known-sensitive container, and left alone otherwise — see [Classification].
*/
class Anonymizer(private val level: PrivacyLevel, private val salt: Salt) {
private val cache = HashMap<String, String>()
fun anonymize(doc: JsonObject): JsonObject {
if (level == PrivacyLevel.FULL) return stamp(doc)
val walked = walkObject(doc, path = emptyList())
val out = if (level == PrivacyLevel.STRICT) strip(walked) else walked
return stamp(out)
}
/** Records what was done, so a reader of the archived/uploaded document is never guessing. */
private fun stamp(doc: JsonObject): JsonObject {
val run = doc["run"]?.jsonObject ?: return doc
val privacy = buildJsonObject {
put("anonymization", level.wire)
put("salt", if (salt.stable) "stable" else "per_run")
}
return JsonObject(doc + ("run" to JsonObject(run + ("privacy" to privacy))))
}
// ---- the tree walk -------------------------------------------------------------------
private fun walkObject(obj: JsonObject, path: List<String>): JsonObject = buildJsonObject {
for ((k, v) in obj) {
val childPath = path + k
when {
Classification.dropAtBalanced(childPath) -> Unit // omit entirely
else -> put(k, walk(k, v, childPath))
}
}
}
private fun walk(key: String, v: JsonElement, path: List<String>): JsonElement = when (v) {
is JsonObject -> walkObject(v, path)
is JsonArray -> JsonArray(v.map { walk(key, it, path) })
is JsonPrimitive ->
if (v.isString) transform(Classification.typeOf(key, path), v.content).let(::JsonPrimitive)
else v
}
private fun transform(type: LogicalType?, value: String): String = when (type) {
null -> value
LogicalType.SSID -> pseudo("ssid", value) { "net-" + it.take(6) }
LogicalType.MAC, LogicalType.BSSID -> macPreservingOui(value)
LogicalType.IP4 -> ip4(value)
LogicalType.IP6 -> ip6(value)
LogicalType.FQDN -> fqdn(value)
LogicalType.OPAQUE_ID -> "redacted"
LogicalType.FREETEXT -> "[removed: may contain identifying text]"
}
// ---- per-type transforms -------------------------------------------------------------
/**
* Keeps the OUI (which vendor) and pseudonymizes the NIC part (which unit). Vendor is the
* diagnostically valuable half — "the RA comes from a MikroTik" survives, "…from THAT
* MikroTik" does not.
*/
private fun macPreservingOui(value: String): String {
val sep = if (value.contains('-')) '-' else ':'
val parts = value.split(sep)
if (parts.size != 6 || parts.any { it.length != 2 }) return pseudo("mac", value) { "mac-" + it.take(8) }
val nic = pseudo("mac", value) { it }
return (parts.take(3) + listOf(nic.substring(0, 2), nic.substring(2, 4), nic.substring(4, 6)))
.joinToString(sep.toString())
.lowercase(Locale.ROOT)
}
/**
* Prefix-preserving within the same class, with reserved ranges kept verbatim: RFC1918 and
* CGNAT addresses say something about the topology and nothing about the person, and a run
* where 192.168.1.1 became a random public address would be actively misleading to read.
* Public addresses keep only their /16 so the network is still locatable at ISP granularity.
*/
private fun ip4(value: String): String {
val o = value.split(".")
if (o.size != 4 || o.any { it.toIntOrNull() == null }) return value
val n = o.map { it.toInt() }
val reserved = n[0] == 10 ||
(n[0] == 172 && n[1] in 16..31) ||
(n[0] == 192 && n[1] == 168) ||
(n[0] == 169 && n[1] == 254) ||
(n[0] == 100 && n[1] in 64..127) ||
n[0] == 127 || n[0] == 0 || n[0] >= 224
if (reserved) return value
val h = pseudo("ip4", value) { it }
return "${n[0]}.${n[1]}.${h.substring(0, 2).toInt(16)}.${h.substring(2, 4).toInt(16)}"
}
/**
* IPv6 keeps the scope and the first 32 bits (so 2001:db8:… still reads as global unicast in
* the same allocation) and pseudonymizes the rest — the interface identifier is the part that
* is a device fingerprint, especially with EUI-64.
*/
private fun ip6(value: String): String {
val v = value.lowercase(Locale.ROOT)
if (v == "::1" || v == "::" || v.startsWith("fe80:") || v.startsWith("ff")) return v
val groups = v.substringBefore('%').split(":")
if (groups.size < 3) return v
val h = pseudo("ip6", value) { it }
return "${groups[0]}:${groups[1]}:${h.substring(0, 4)}:${h.substring(4, 8)}::${h.substring(8, 12)}"
}
/**
* Per-label pseudonyms with the public suffix kept, so "it resolved somewhere under .local"
* or "…under example.com" survives without naming the host. The suffix list is deliberately
* short: guessing wrong keeps *more* pseudonymized, never less.
*/
private fun fqdn(value: String): String {
if (value.isEmpty()) return value
val trailing = value.endsWith(".")
val labels = value.trimEnd('.').split(".")
if (labels.size == 1) return pseudo("fqdn", value) { "host-" + it.take(6) }
val keep = if (labels.last() in publicSuffixes) 1 else 0
val head = labels.dropLast(keep).map { l -> pseudo("label", l) { "l-" + it.take(6) } }
return (head + labels.takeLast(keep)).joinToString(".") + if (trailing) "." else ""
}
// ---- STRICT ---------------------------------------------------------------------------
/**
* STRICT keeps the shape of the document and the numbers, and nothing that quotes the
* network back. Evidence goes (trains carry addresses and hostnames), finding prose goes
* (it interpolates real names), networks go entirely.
*/
private fun strip(doc: JsonObject): JsonObject = buildJsonObject {
for ((k, v) in doc) {
when (k) {
"networks", "server_sessions" -> Unit
"tests" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { t ->
val o = t.jsonObject
JsonObject(o.filterKeys { it != "evidence" && it != "params" })
}))
"findings" -> put(k, JsonArray((v as? JsonArray ?: JsonArray(emptyList())).map { f ->
val o = f.jsonObject
JsonObject(o.filterKeys { it != "description" && it != "title" && it != "evidence_refs" })
}))
"run" -> put(k, JsonObject(v.jsonObject.filterKeys { it != "notes" }))
else -> put(k, v)
}
}
}
// ---- pseudonym machinery ---------------------------------------------------------------
/** Deterministic per (domain, value, salt); memoized so one value maps to one pseudonym. */
private fun pseudo(domain: String, value: String, shape: (String) -> String): String =
cache.getOrPut("$domain$value") {
val md = MessageDigest.getInstance("SHA-256")
md.update(salt.bytes)
md.update(domain.toByteArray())
md.update(0)
md.update(value.lowercase(Locale.ROOT).toByteArray())
shape(md.digest().joinToString("") { "%02x".format(it) })
}
private companion object {
val publicSuffixes = setOf(
"local", "lan", "home", "internal", "arpa",
"com", "net", "org", "io", "app", "dev", "at", "de", "eu", "uk",
)
}
}
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
/** The logical types of measurement-schema.md §8. */
enum class LogicalType { IP4, IP6, MAC, BSSID, SSID, FQDN, OPAQUE_ID, FREETEXT }
/**
* Which fields hold which logical type, and which whole subtrees are dropped below FULL.
*
* This is a table on purpose. The alternative annotating the Kotlin models and reflecting over
* them spreads the answer across every module and makes "what exactly gets uploaded?" a
* question you answer by reading the whole app. Here it is one file a reviewer can check against
* the spec in a sitting, and a new field that nobody classified stays visible in the output
* rather than being silently mangled.
*
* The bias is toward over-classifying: a metric wrongly pseudonymized is a bug someone reports;
* an SSID wrongly kept is a leak nobody notices.
*/
object Classification {
private val byKey: Map<String, LogicalType> = buildMap {
listOf(
"ip4", "ipv4", "gateway_ip4", "dns_ip4", "src_ip4", "dst_ip4", "public_ip4",
"observed_ip4", "hop_ip4", "answer_ip4", "address_ip4", "server_ip4",
).forEach { put(it, LogicalType.IP4) }
listOf(
"ip6", "ipv6", "gateway_ip6", "dns_ip6", "src_ip6", "dst_ip6", "public_ip6",
"observed_ip6", "hop_ip6", "answer_ip6", "address_ip6", "server_ip6",
"link_local", "ra_source", "prefix",
).forEach { put(it, LogicalType.IP6) }
listOf("mac", "hw_addr", "gateway_mac", "router_mac", "sender_mac", "peer_mac")
.forEach { put(it, LogicalType.MAC) }
listOf("bssid", "ap_mac").forEach { put(it, LogicalType.BSSID) }
listOf("ssid", "network_name", "wifi_ssid").forEach { put(it, LogicalType.SSID) }
listOf(
"fqdn", "hostname", "host", "name", "reverse_dns", "ptr", "domain", "query_name",
"friendly_name", "server_name", "sni", "cname", "search_domain", "device_name",
).forEach { put(it, LogicalType.FQDN) }
listOf("session_id", "credential", "token", "device_id", "android_id", "serial", "imsi", "iccid")
.forEach { put(it, LogicalType.OPAQUE_ID) }
listOf("notes", "detail", "raw", "excerpt", "location", "model_description")
.forEach { put(it, LogicalType.FREETEXT) }
}
/**
* Whole subtrees that BALANCED removes rather than pseudonymizes.
*
* Neighbour inventories (SSDP/UPnP responders, ARP tables, discovered peers) are the clearest
* case: they describe other people's devices, they are a household fingerprint even with the
* names hashed, and no metric depends on them. Dropping beats mangling.
*/
private val droppedPaths: List<List<String>> = listOf(
listOf("networks", "neighbors"),
listOf("networks", "arp"),
listOf("networks", "wifi", "scan_results"),
listOf("run", "device", "security_patch"),
)
/** Key suffixes whose whole value is a neighbour inventory wherever they appear. */
private val droppedKeys = setOf(
"ssdp_responders", "upnp", "neighbors", "arp_table", "scan_results",
"nearby_networks", "peers", "raw_dump", "dumpsys",
)
fun typeOf(key: String, path: List<String>): LogicalType? {
byKey[key]?.let { return it }
// Inside a discovery/neighbour container every string is someone's device name until
// proven otherwise, so classify unknown strings there as free text rather than passing
// them through.
if (path.any { it in droppedKeys }) return LogicalType.FREETEXT
return null
}
fun dropAtBalanced(path: List<String>): Boolean {
if (path.isNotEmpty() && path.last() in droppedKeys) return true
return droppedPaths.any { dropped -> dropped.all { path.contains(it) } }
}
}
@@ -0,0 +1,185 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.privacy
import kotlinx.serialization.json.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* These tests are the audit of the anonymizer: each one states a property someone's privacy
* depends on, so a regression here fails loudly rather than quietly leaking.
*/
class AnonymizerTest {
private val json = Json { prettyPrint = false }
private val salt = Salt.perRun(ByteArray(32) { it.toByte() })
private fun sample(): JsonObject = json.parseToJsonElement(
"""
{
"schema": "echolot/measurement",
"run": {
"id": "0190-run", "trigger": "manual", "notes": "at Anna's flat",
"device": {"manufacturer": "OnePlus", "model": "CPH2747", "security_patch": "2026-06-05"}
},
"networks": [{
"id": "net-1", "ssid": "Rambossek WLAN", "bssid": "78:9a:18:aa:bb:cc",
"gateway_ip4": "192.168.1.1", "public_ip4": "89.185.109.150",
"gateway_ip6": "2001:1ad0:c4fe:6767::1", "link_local": "fe80::7a9a:18ff:feaa:bbcc",
"neighbors": [{"name": "Anna's Chromecast", "mac": "aa:bb:cc:dd:ee:ff"}],
"ssdp_responders": [{"friendly_name": "Living Room TV", "location": "http://192.168.1.44:8060/"}]
}],
"tests": [{
"id": "t1", "type": "train.udp_updown", "status": "ok",
"metrics": {"rtt_ms_avg": 12.4, "loss_pct": 0.0},
"evidence": {"seq": [0,1,2], "t_rx_ns": [1,2,3]}
}],
"findings": [{
"id": "f1", "code": "nat.udp_rebinding", "severity": "medium",
"title": "NAT remapped the port", "description": "server saw 89.185.109.150:41000"
}],
"summary": {"verdict": "warn"}
}
""".trimIndent(),
).jsonObject
private fun anon(level: PrivacyLevel, doc: JsonObject = sample()) = Anonymizer(level, salt).anonymize(doc)
private fun flat(e: JsonElement): String = e.toString()
@Test
fun fullLeavesTheDocumentAloneButRecordsThat() {
val out = anon(PrivacyLevel.FULL)
assertEquals("Rambossek WLAN", out["networks"]!!.jsonArray[0].jsonObject["ssid"]!!.jsonPrimitive.content)
assertEquals("full", out["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content)
}
@Test
fun balancedRemovesTheSsidAndTheNotes() {
val text = flat(anon(PrivacyLevel.BALANCED))
assertFalse(text.contains("Rambossek"), "SSID survived: $text")
assertFalse(text.contains("Anna"), "free-text note or neighbour name survived: $text")
}
@Test
fun balancedDropsNeighbourInventoriesEntirely() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertNull(net["neighbors"], "neighbour list should be dropped, not pseudonymized")
assertNull(net["ssdp_responders"], "SSDP responders should be dropped, not pseudonymized")
}
@Test
fun balancedKeepsTheVendorHalfOfAMac() {
val bssid = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject["bssid"]!!.jsonPrimitive.content
assertTrue(bssid.startsWith("78:9a:18"), "OUI should survive so the vendor is still known: $bssid")
assertFalse(bssid.endsWith("aa:bb:cc"), "NIC part should be pseudonymized: $bssid")
}
@Test
fun privateAddressesAreKeptVerbatimAndPublicOnesAreNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("192.168.1.1", net["gateway_ip4"]!!.jsonPrimitive.content,
"RFC1918 says nothing about the user and everything about the topology")
assertNotEquals("89.185.109.150", net["public_ip4"]!!.jsonPrimitive.content)
assertTrue(net["public_ip4"]!!.jsonPrimitive.content.startsWith("89.185."),
"the /16 should survive for ISP-level context")
}
@Test
fun linkLocalIsKeptButGlobalV6IsNot() {
val net = anon(PrivacyLevel.BALANCED)["networks"]!!.jsonArray[0].jsonObject
assertEquals("fe80::7a9a:18ff:feaa:bbcc", net["link_local"]!!.jsonPrimitive.content)
assertNotEquals("2001:1ad0:c4fe:6767::1", net["gateway_ip6"]!!.jsonPrimitive.content)
}
@Test
fun metricsAndVerdictsAreNeverTouched() {
for (level in PrivacyLevel.entries) {
val out = anon(level)
val t = out["tests"]!!.jsonArray[0].jsonObject
assertEquals(12.4, t["metrics"]!!.jsonObject["rtt_ms_avg"]!!.jsonPrimitive.double, 1e-9,
"$level changed a metric")
assertEquals("ok", t["status"]!!.jsonPrimitive.content)
assertEquals("warn", out["summary"]!!.jsonObject["verdict"]!!.jsonPrimitive.content)
}
}
@Test
fun findingCodesSurviveEveryLevelSoAggregationStillWorks() {
for (level in PrivacyLevel.entries) {
val f = anon(level)["findings"]!!.jsonArray[0].jsonObject
assertEquals("nat.udp_rebinding", f["code"]!!.jsonPrimitive.content, "$level lost the finding code")
assertEquals("medium", f["severity"]!!.jsonPrimitive.content)
}
}
@Test
fun strictDropsEvidenceAndProse() {
val out = anon(PrivacyLevel.STRICT)
assertNull(out["networks"], "STRICT should not describe the network at all")
assertNull(out["tests"]!!.jsonArray[0].jsonObject["evidence"])
assertNull(out["findings"]!!.jsonArray[0].jsonObject["description"])
assertFalse(flat(out).contains("89.185.109.150"), "an address leaked through finding prose")
}
@Test
fun pseudonymsAreConsistentWithinADocument() {
val doc = json.parseToJsonElement(
"""{"run":{"id":"r"},"networks":[{"ssid":"Home"},{"ssid":"Home"},{"ssid":"Other"}]}"""
).jsonObject
val nets = anon(PrivacyLevel.BALANCED, doc)["networks"]!!.jsonArray
val a = nets[0].jsonObject["ssid"]!!.jsonPrimitive.content
val b = nets[1].jsonObject["ssid"]!!.jsonPrimitive.content
val c = nets[2].jsonObject["ssid"]!!.jsonPrimitive.content
assertEquals(a, b, "the same SSID must map to the same pseudonym inside one run")
assertNotEquals(a, c, "different SSIDs must not collide")
}
@Test
fun perRunSaltsDoNotLinkTwoUploadsOfTheSameNetwork() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 1 })).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.perRun(ByteArray(32) { 2 })).anonymize(doc)
assertNotEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
"a per-run salt must not produce a cross-run tracking identifier",
)
}
@Test
fun aStableSaltDoesLinkThemBecauseThatIsWhatItIsFor() {
val doc = json.parseToJsonElement("""{"run":{"id":"r"},"networks":[{"ssid":"Home"}]}""").jsonObject
val secret = ByteArray(32) { 7 }
val one = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
val two = Anonymizer(PrivacyLevel.BALANCED, Salt.stable(secret)).anonymize(doc)
assertEquals(
one["networks"]!!.jsonArray[0].jsonObject["ssid"],
two["networks"]!!.jsonArray[0].jsonObject["ssid"],
)
assertEquals("stable", one["run"]!!.jsonObject["privacy"]!!.jsonObject["salt"]!!.jsonPrimitive.content)
}
@Test
fun theDeclaredLevelMatchesWhatWasApplied() {
for (level in PrivacyLevel.entries) {
assertEquals(
level.wire,
anon(level)["run"]!!.jsonObject["privacy"]!!.jsonObject["anonymization"]!!.jsonPrimitive.content,
)
}
}
@Test
fun serverMinimumWins() {
assertEquals(PrivacyLevel.STRICT, PrivacyLevel.max(PrivacyLevel.FULL, PrivacyLevel.STRICT))
assertEquals(PrivacyLevel.BALANCED, PrivacyLevel.max(PrivacyLevel.BALANCED, PrivacyLevel.FULL))
assertEquals(PrivacyLevel.FULL, PrivacyLevel.fromWire("nonsense"))
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
// AGP 9 has built-in Kotlin — do NOT also apply kotlin.android (double-registers
// the `kotlin` extension). Only the serialization compiler plugin is added.
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
// Device-tier probes (Android platform APIs), emitting core-measurement Test
// objects. Ported/adapted from the validated echolot-prober. minSdk 26 to
// match the prober and the feasibility findings.
android {
namespace = "app.echo_lot.probe"
compileSdk = 36
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":core-measurement"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.android)
}
@@ -0,0 +1,119 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.Network
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* Reproduces Android's own "do I have internet / is there a captive portal?" logic
* (NetworkMonitor): it fetches `generate_204` endpoints and checks for **HTTP 204 No Content**.
*
* Per active network (bound via Network.openConnection):
* - **HTTPS 204** (`https://www.google.com/generate_204`) → real validated internet.
* - **HTTP 204** (`http://connectivitycheck.gstatic.com/generate_204`) → a plain-HTTP path with
* no interference. A 3xx redirect or a 200-with-body instead of 204 is the classic **captive
* portal** signature (the portal's login page); the redirect Location is captured.
* - timeout/IO error on both no working internet on that network.
*
* These are the AOSP default probe URLs (Settings.Global CAPTIVE_PORTAL_HTTPS_URL /
* CAPTIVE_PORTAL_HTTP_URL). Redirects are NOT followed an unfollowed 3xx is the evidence.
*/
class CaptivePortalProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.NET_CAPTIVE_PORTAL
override val tier = Tier.APP
override val estimatedMs = 9_000L // two HTTP probes per network, 4s timeouts each
private val httpsUrl = "https://www.google.com/generate_204"
private val httpUrl = "http://connectivitycheck.gstatic.com/generate_204"
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
val perNet = LinkedHashMap<String, ProbeResult>()
// Default network first, then each active network explicitly.
perNet["default"] = validate(null)
for (e in entries) {
perNet["${e.model.transport.name.lowercase()}:${e.model.id}"] = validate(e.handle)
}
val evidence: JsonObject = buildJsonObject {
put("https_url", httpsUrl); put("http_url", httpUrl)
for ((label, r) in perNet) putJsonObject(label) {
put("https_code", r.httpsCode); put("http_code", r.httpCode)
r.portalLocation?.let { put("portal_location", it) }
put("verdict", r.verdict)
}
}
// Best verdict across networks: validated > portal > none.
val anyValidated = perNet.values.any { it.verdict == "validated" }
val anyPortal = perNet.values.any { it.verdict == "captive_portal" }
val status = when {
anyValidated -> TestStatus.OK
anyPortal -> TestStatus.PARTIAL // reachable but intercepted
else -> TestStatus.FAILED // no working internet anywhere
}
b.build(status, evidence = evidence)
}
private data class ProbeResult(
val httpsCode: Int, val httpCode: Int, val portalLocation: String?, val verdict: String,
)
private fun validate(network: Network?): ProbeResult {
val https = probe(network, httpsUrl)
val http = probe(network, httpUrl)
val portalLoc = http.location.takeIf { http.code in 300..399 }
val verdict = when {
https.code == 204 -> "validated" // real internet
http.code == 204 -> "validated_http_only" // HTTP clean, HTTPS blocked
http.code in 300..399 || (http.code == 200 && http.hadBody) -> "captive_portal"
https.code < 0 && http.code < 0 -> "no_internet"
else -> "inconclusive"
}
return ProbeResult(https.code, http.code, portalLoc, verdict)
}
private data class Resp(val code: Int, val location: String?, val hadBody: Boolean)
/** One probe: code (-1 on failure), Location header, and whether a body was present (204 has none). */
private fun probe(network: Network?, urlStr: String): Resp {
var conn: HttpURLConnection? = null
return try {
val url = URL(urlStr)
conn = (network?.openConnection(url) ?: url.openConnection()) as HttpURLConnection
conn.instanceFollowRedirects = false // an unfollowed 3xx is the portal signal
conn.connectTimeout = 4000
conn.readTimeout = 4000
conn.requestMethod = "GET"
conn.setRequestProperty("User-Agent", "Echolot")
conn.setRequestProperty("Connection", "close")
val code = conn.responseCode
val loc = conn.getHeaderField("Location")
val body = runCatching {
(conn.inputStream ?: conn.errorStream)?.use { it.read() != -1 }
}.getOrNull() ?: false
Resp(code, loc, body)
} catch (e: IOException) {
Resp(-1, null, false)
} catch (e: Throwable) {
Resp(-1, null, false)
} finally {
conn?.disconnect()
}
}
}
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import java.net.InetAddress
/**
* dns.canary / dns.answer_integrity resolves the server's canary zone through the network's own
* resolver and compares against the spec-frozen ground truth (probe-protocol.md §6.1).
*
* Two things are checked, and they detect different failures:
* - **Reference records** (`ttl-5`, `many-rr`, ) have FIXED RDATA fixed by the spec, so a
* mismatch means the answer was rewritten in flight (interception/filtering).
* - A **per-run nonce name** `<nonce>.<session>.<zone>` can never have been cached, so it proves
* the query reached the authoritative server, and the answer is derived from the nonce itself.
*
* Resolution goes through the platform resolver (InetAddress), i.e. exactly the path apps use
* so interception by the network's DNS is what we measure. The server side records who actually
* asked (its observation API), letting the app pair "what I got" with "who asked".
*/
class DnsCanaryProbe(
private val canaryZone: String,
private val sessionPrefix: String,
private val nonce: String = java.util.UUID.randomUUID().toString().take(8),
) : Probe {
override val type = TestType.DNS_CANARY
override val tier = Tier.APP
override val estimatedMs = 3_000L // five resolutions through the platform resolver
/** Frozen ground truth from probe-protocol.md §6.1 — must match the server's dns_reference.go. */
private val references = listOf(
Reference("ttl-5", "192.0.2.5"),
Reference("ttl-60", "192.0.2.60"),
Reference("ttl-3600", "192.0.2.36"),
Reference("ttl-86400", "192.0.2.86"),
)
private data class Reference(val label: String, val expectedA: String)
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
if (canaryZone.isBlank()) {
return@withContext b.build(
TestStatus.SKIPPED,
evidence = buildJsonObject { put("reason", "no canary zone configured (needs a server profile)") },
)
}
var matched = 0
var mismatched = 0
var failed = 0
val evidence: JsonObject = buildJsonObject {
put("zone", canaryZone)
putJsonObject("reference_records") {
for (r in references) {
val fqdn = "${r.label}.$canaryZone"
val got = resolveA(fqdn)
putJsonObject(r.label) {
put("fqdn", fqdn); put("expected", r.expectedA); put("got", got ?: "")
val verdict = when {
got == null -> { failed++; "resolve_failed" }
got == r.expectedA -> { matched++; "match" }
else -> { mismatched++; "MISMATCH (answer rewritten in flight)" }
}
put("verdict", verdict)
}
}
}
// Cache-miss proof: a nonce name that cannot have been pre-cached.
val nonceFqdn = "$nonce.$sessionPrefix.$canaryZone"
val nonceGot = resolveA(nonceFqdn)
putJsonObject("nonce_query") {
put("fqdn", nonceFqdn)
put("got", nonceGot ?: "")
// The server answers nonce names from 192.0.2.0/24 (deterministic per nonce).
val reached = nonceGot?.startsWith("192.0.2.") == true
put("reached_authoritative", reached)
put("note", "a non-192.0.2.x answer means something other than the canary server replied")
}
}
val metrics = buildJsonObject {
put("references_matched", matched); put("references_mismatched", mismatched)
put("references_failed", failed)
}
val status = when {
mismatched > 0 -> TestStatus.PARTIAL // answers altered — a finding
matched == 0 -> TestStatus.FAILED // nothing resolved
failed > 0 -> TestStatus.PARTIAL
else -> TestStatus.OK
}
b.build(status, evidence = evidence, metrics = metrics)
}
/** First IPv4 answer via the platform resolver (the path a normal app takes), or null. */
private fun resolveA(fqdn: String): String? = runCatching {
InetAddress.getAllByName(fqdn).firstOrNull { it is java.net.Inet4Address }?.hostAddress
}.getOrNull()
}
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.Network
import android.system.Os
import android.system.OsConstants
import android.system.StructTimeval
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.io.FileDescriptor
import java.net.InetAddress
import java.nio.ByteBuffer
import java.util.Locale
/**
* icmp.ping4 / icmp.ping6 via the unprivileged ICMP datagram socket, per active network
* (Network.bindSocket). Ported from the prober, which validated on real hardware that Android's
* open ping_group_range makes this work with no root and that per-network binding turns a
* default-network v6 EAGAIN into topology evidence rather than a false failure.
*/
class IcmpProbe(
private val entries: List<NetworkInventory.Entry>,
private val v6: Boolean,
private val target: String = if (v6) "2606:4700:4700::1111" else "1.1.1.1",
) : Probe {
override val type = if (v6) TestType.ICMP_PING6 else TestType.ICMP_PING4
override val tier = Tier.APP
// v6 on a v4-only network waits out a 3s timeout per network; v4 answers in ms.
override val estimatedMs = if (v6) 7_000L else 800L
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
val perNetwork = LinkedHashMap<String, String>()
var anyOk = false
val rtts = ArrayList<Double>()
// Default network first, then each active network explicitly.
attempt(null).let { (ok, detail, rtt) ->
perNetwork["default"] = detail; if (ok) { anyOk = true; rtt?.let(rtts::add) }
}
for (e in entries) {
val label = "${e.model.transport.name.lowercase()}:${e.model.id}"
val (ok, detail, rtt) = attempt(e.handle)
perNetwork[label] = detail
if (ok) { anyOk = true; rtt?.let(rtts::add) }
}
val evidence: JsonObject = buildJsonObject {
put("target", target)
for ((k, v) in perNetwork) put(k, v)
}
val metrics: JsonObject = buildJsonObject {
put("networks_ok", rtts.size)
rtts.minOrNull()?.let { put("rtt_ms_min", round1(it)) }
if (rtts.isNotEmpty()) put("rtt_ms_avg", round1(rtts.average()))
rtts.maxOrNull()?.let { put("rtt_ms_max", round1(it)) }
}
val status = if (anyOk) TestStatus.OK else TestStatus.FAILED
b.build(status, evidence = evidence, metrics = metrics)
}
private data class Attempt(val ok: Boolean, val detail: String, val rttMs: Double?)
private fun attempt(network: Network?): Attempt {
var fd: FileDescriptor? = null
return try {
val proto = if (v6) OsConstants.IPPROTO_ICMPV6 else OsConstants.IPPROTO_ICMP
val family = if (v6) OsConstants.AF_INET6 else OsConstants.AF_INET
fd = Os.socket(family, OsConstants.SOCK_DGRAM, proto)
network?.bindSocket(fd)
Os.setsockoptTimeval(fd, OsConstants.SOL_SOCKET, OsConstants.SO_RCVTIMEO, StructTimeval.fromMillis(3000))
val addr = network?.getByName(target) ?: InetAddress.getByName(target)
val ident = (Os.getpid() and 0xFFFF)
val packet = buildEchoRequest(v6, ident.toShort(), 1)
val t0 = System.nanoTime()
Os.sendto(fd, packet, 0, packet.size, 0, addr, 0)
val buf = ByteBuffer.allocate(1500)
val received = Os.recvfrom(fd, buf, 0, null)
val rttMs = (System.nanoTime() - t0) / 1_000_000.0
val replyType = if (received > 0) buf.get(0).toInt() and 0xFF else -1
val ok = replyType == (if (v6) 129 else 0)
Attempt(ok, "reply type=$replyType rtt_ms=${"%.1f".format(Locale.ROOT, rttMs)} bytes=$received", if (ok) rttMs else null)
} catch (e: Throwable) {
Attempt(false, "error: ${e.message ?: e.javaClass.simpleName}", null)
} finally {
fd?.let { runCatching { Os.close(it) } }
}
}
private fun buildEchoRequest(v6: Boolean, ident: Short, seq: Short): ByteArray {
val type = if (v6) 128 else 8
val payload = "echolot".toByteArray()
val pkt = ByteBuffer.allocate(8 + payload.size)
pkt.put(type.toByte()); pkt.put(0); pkt.putShort(0)
pkt.putShort(ident); pkt.putShort(seq); pkt.put(payload)
val bytes = pkt.array()
if (!v6) {
val cs = checksum(bytes)
bytes[2] = (cs.toInt() shr 8).toByte(); bytes[3] = (cs.toInt() and 0xFF).toByte()
}
return bytes
}
private fun checksum(b: ByteArray): Short {
var sum = 0; var i = 0
while (i < b.size - 1) { sum += ((b[i].toInt() and 0xFF) shl 8) or (b[i + 1].toInt() and 0xFF); i += 2 }
if (i < b.size) sum += (b[i].toInt() and 0xFF) shl 8
while (sum shr 16 != 0) sum = (sum and 0xFFFF) + (sum shr 16)
return sum.inv().toShort()
}
private companion object {
fun round1(v: Double) = Math.round(v * 10.0) / 10.0
}
}
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.addJsonObject
/**
* link.snapshot: records every active network's LinkProperties as evidence. The full network
* models also feed the document's `networks[]` (see [NetworkInventory]); this test captures the
* count and a compact per-network summary so the snapshot is attributable in `tests[]`.
*/
class LinkSnapshotProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.LINK_SNAPSHOT
override val tier = Tier.APP
override val estimatedMs = 200L // reads LinkProperties, no I/O
override suspend fun run(ctx: Context, ids: ProbeIds): Test {
val b = TestBuilder(type, tier, ids)
val evidence: JsonObject = buildJsonObject {
put("network_count", entries.size)
putJsonArray("networks") {
for (e in entries) addJsonObject {
put("id", e.model.id)
put("transport", e.model.transport.name.lowercase())
put("interface", e.model.iface ?: "")
put("mtu", e.model.link.mtu ?: 0)
put("addresses", e.model.link.addresses.joinToString(", ") { "${it.addr}/${it.prefixLen}" })
put("dns", (e.model.link.dns?.servers ?: emptyList()).joinToString(", "))
put("nat64", e.model.link.dns?.nat64Prefix ?: "none")
}
}
}
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
return b.build(status, evidence = evidence)
}
}
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.NetworkCapabilities
import app.echo_lot.measurement.Address
import app.echo_lot.measurement.DnsConfig
import app.echo_lot.measurement.Link
import app.echo_lot.measurement.Route
import app.echo_lot.measurement.Transport
import app.echo_lot.measurement.Network as MNetwork
/**
* Reads the app-tier snapshot of every active Android Network into measurement `networks[]`
* (measurement-schema.md §4). App tier fills what LinkProperties exposes; route proto and address
* lifetimes are Shizuku-tier and left absent (absence = "not observed"). Ported from the prober's
* LinkPropertiesProbe.
*/
object NetworkInventory {
/** One measurement Network per active Android Network, plus the Android Network handle so
* server/ICMP probes can bind to it. */
data class Entry(val model: MNetwork, val handle: android.net.Network)
fun snapshot(ctx: Context): List<Entry> {
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val out = ArrayList<Entry>()
var idx = 0
for (net in cm.allNetworks) {
val caps = cm.getNetworkCapabilities(net) ?: continue
val lp = cm.getLinkProperties(net) ?: continue
out.add(Entry(model = toModel("net-${idx}", caps, lp), handle = net))
idx++
}
return out
}
private fun toModel(id: String, caps: NetworkCapabilities, lp: LinkProperties): MNetwork {
val transport = when {
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> Transport.WIFI
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> Transport.CELLULAR
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> Transport.ETHERNET
caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> Transport.VPN
else -> Transport.OTHER
}
val addresses = lp.linkAddresses.map {
Address(
addr = it.address.hostAddress ?: it.address.toString(),
prefixLen = it.prefixLength,
scope = null,
)
}
val routes = lp.routes.map {
Route(
dst = it.destination.toString(),
gateway = it.gateway?.hostAddress,
iface = it.`interface`,
)
}
val nat64 = runCatching { lp.nat64Prefix?.toString() }.getOrNull()
val dns = DnsConfig(
servers = lp.dnsServers.mapNotNull { it.hostAddress },
privateDnsMode = if (lp.isPrivateDnsActive) "strict/opportunistic" else "off",
privateDnsHostname = lp.privateDnsServerName,
searchDomains = lp.domains?.split(",")?.map { it.trim() } ?: emptyList(),
nat64Prefix = nat64,
)
return MNetwork(
id = id, transport = transport, iface = lp.interfaceName,
link = Link(mtu = lp.mtu.takeIf { it > 0 }, addresses = addresses, routes = routes, dns = dns),
)
}
}
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
/**
* Minimal OUI vendor lookup for identifying gateways/routers from a MAC address.
*
* Deliberately a small curated table rather than the full IEEE registry (~35k entries, ~1.5 MB):
* the goal is naming the box that routes a home/office LAN, and consumer/SOHO gear concentrates
* in a handful of vendors. An unknown OUI is reported verbatim so it is never silently wrong
* and the SSDP/UPnP identity in [RouterIdentityProbe] usually names the exact model anyway.
*/
object Oui {
private val table: Map<String, String> = mapOf(
// AVM (FRITZ!Box) — dominant in DE/AT
"00:04:0E" to "AVM", "38:10:D5" to "AVM", "5C:49:79" to "AVM", "C8:0E:14" to "AVM",
"3C:A6:2F" to "AVM", "9C:C7:A6" to "AVM", "E0:28:6D" to "AVM", "24:65:11" to "AVM",
// Ubiquiti
"00:15:6D" to "Ubiquiti", "04:18:D6" to "Ubiquiti", "24:5A:4C" to "Ubiquiti",
"44:D9:E7" to "Ubiquiti", "68:72:51" to "Ubiquiti", "78:8A:20" to "Ubiquiti",
"74:AC:B9" to "Ubiquiti", "F0:9F:C2" to "Ubiquiti", "B4:FB:E4" to "Ubiquiti",
"E0:63:DA" to "Ubiquiti", "78:45:58" to "Ubiquiti", "AC:8B:A9" to "Ubiquiti",
// MikroTik
"00:0C:42" to "MikroTik", "4C:5E:0C" to "MikroTik", "6C:3B:6B" to "MikroTik",
"48:8F:5A" to "MikroTik", "2C:C8:1B" to "MikroTik", "DC:2C:6E" to "MikroTik",
"78:9A:18" to "MikroTik", "18:FD:74" to "MikroTik", "64:D1:54" to "MikroTik",
"74:4D:28" to "MikroTik", "C4:AD:34" to "MikroTik", "E4:8D:8C" to "MikroTik",
// TP-Link
"00:1D:0F" to "TP-Link", "14:CC:20" to "TP-Link", "50:C7:BF" to "TP-Link",
"A4:2B:B0" to "TP-Link", "C0:06:C3" to "TP-Link", "EC:08:6B" to "TP-Link",
// Netgear
"00:09:5B" to "Netgear", "20:4E:7F" to "Netgear", "A0:40:A0" to "Netgear",
"C4:04:15" to "Netgear", "9C:3D:CF" to "Netgear",
// ASUS
"00:1B:FC" to "ASUS", "2C:56:DC" to "ASUS", "50:46:5D" to "ASUS", "AC:9E:17" to "ASUS",
"04:D9:F5" to "ASUS", "1C:B7:2C" to "ASUS",
// Cisco / Meraki
"00:1A:2F" to "Cisco", "00:26:99" to "Cisco", "E0:CB:BC" to "Cisco",
"00:18:0A" to "Cisco Meraki", "88:15:44" to "Cisco Meraki", "E0:55:3D" to "Cisco Meraki",
// Zyxel / Draytek / Huawei / ZTE
"00:13:49" to "Zyxel", "5C:F4:AB" to "Zyxel", "00:1D:AA" to "DrayTek",
"00:E0:FC" to "Huawei", "48:46:FB" to "Huawei", "00:1E:73" to "ZTE",
// AVM-adjacent ISP CPE / others common on consumer LANs
"00:17:3F" to "Belkin", "B8:27:EB" to "Raspberry Pi", "DC:A6:32" to "Raspberry Pi",
"E4:5F:01" to "Raspberry Pi", "00:50:56" to "VMware", "52:54:00" to "QEMU/KVM",
"18:E8:29" to "Ubiquiti", "70:A7:41" to "Ubiquiti",
)
/** Vendor for a MAC, or null when the OUI isn't in the curated table. */
fun vendor(mac: String): String? {
val norm = mac.uppercase().replace('-', ':').trim()
if (norm.length < 8) return null
return table[norm.substring(0, 8)]
}
/** True for a locally-administered (often randomized) MAC — not a real vendor identity. */
fun isLocallyAdministered(mac: String): Boolean {
val first = mac.replace('-', ':').split(':').firstOrNull() ?: return false
val b = first.toIntOrNull(16) ?: return false
return (b and 0x02) != 0
}
}
@@ -0,0 +1,63 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Package probe holds the device-tier probes (app + shizuku tiers). Each probe runs a platform
// measurement and returns a core-measurement [Test] — raw evidence + recomputable metrics — never
// throwing to the caller. Ported from the validated echolot-prober, now emitting the production
// schema instead of the prober's ad-hoc format.
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestError
import app.echo_lot.measurement.Tier
import kotlinx.serialization.json.JsonObject
/** A single device-tier measurement. [type] is a TestType registry id. */
interface Probe {
val type: String
val tier: Tier
/**
* Rough wall-clock estimate in ms, used only to drive the progress bar / ETA. Probes that
* wait on timeouts (ICMPv6 on a v4-only net, SSDP, STUN) dominate a run, so they override
* this with realistic values measured on device a bad estimate misleads the user, it does
* not break the run.
*/
val estimatedMs: Long get() = 2_000
/** Runs the probe. [ctx] gives platform access; [ids] supplies UUIDs + the monotonic clock so
* results are attributable and use the two-clock rule. Must never throw. */
suspend fun run(ctx: Context, ids: ProbeIds): Test
}
/** Injected UUID/clock source (measurement-schema.md: UUIDv7 ids, *_mono_ns math clock). */
interface ProbeIds {
fun uuid(): String
/** Monotonic nanoseconds relative to the run's mono origin. */
fun monoNs(): Long
}
/** Builds a [Test] envelope, capturing start/end from the shared clock. */
class TestBuilder(
private val type: String,
private val tier: Tier,
private val ids: ProbeIds,
private val networkRef: String? = null,
private val sessionRef: String? = null,
) {
private val id = ids.uuid()
private val startedMonoNs = ids.monoNs()
fun build(
status: TestStatus,
evidence: JsonObject? = null,
metrics: JsonObject? = null,
error: TestError? = null,
): Test = Test(
id = id, type = type, networkRef = networkRef, sessionRef = sessionRef, tier = tier,
startedMonoNs = startedMonoNs, endedMonoNs = ids.monoNs(),
status = status, error = error, evidence = evidence, metrics = metrics,
)
}
@@ -0,0 +1,207 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.addJsonObject
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URL
/**
* link.ra_source identifies **who is advertising IPv6 on this network** (and the IPv4 gateway),
* with as much attribution as an unprivileged app can gather.
*
* Why it matters: a rogue or misconfigured RA sender is one of the most common causes of broken
* IPv6, and "some router advertises a default route" is useless without knowing *which box*.
*
* Identification chain, best-effort and each step recorded as evidence:
* 1. **RA source** the next-hop of the `::/0` route (a link-local `fe80::` address) per network.
* 2. **MAC from EUI-64** a link-local formed the classic way encodes the sender's MAC
* (`fe80::7a9a:18ff:fe54:b8f9` `78:9a:18:54:b8:f9`): strip `ff:fe` from the middle and flip
* the U/L bit. Privacy/stable-private addresses (RFC 7217) don't encode it reported as such
* rather than guessed.
* 3. **Vendor** OUI lookup on that MAC ([Oui]).
* 4. **UPnP/SSDP** an M-SEARCH usually gets the router itself to answer with a `SERVER:` banner
* and a device-description URL; fetching it yields manufacturer / model / friendly name. This
* is what usually pins down the exact box.
* 5. **Reverse DNS** for the gateway addresses.
*
* Future cross-matching (same MAC seen via LLDP or in an SSDP/mDNS inventory) is why the MAC is
* always reported alongside every identity source.
*/
class RouterIdentityProbe(private val entries: List<NetworkInventory.Entry>) : Probe {
override val type = TestType.LINK_RA_SOURCE
override val tier = Tier.APP
override val estimatedMs = 7_000L // SSDP M-SEARCH window + description fetches + rDNS
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
val ssdp = ssdpDiscover() // ip -> (server banner, description url)
var raSenders = 0
val evidence: JsonObject = buildJsonObject {
putJsonArray("networks") {
for (e in entries) {
val n = e.model
val v6Gw = n.link.routes.firstOrNull { it.dst == "::/0" }?.gateway
val v4Gw = n.link.routes.firstOrNull { it.dst == "0.0.0.0/0" }?.gateway
addJsonObject {
put("network", "${n.transport.name.lowercase()}:${n.id}")
put("interface", n.iface ?: "")
// --- IPv6 RA sender ---
put("ra_source", v6Gw ?: "(none — no IPv6 default route)")
if (v6Gw != null) {
raSenders++
val mac = macFromEui64LinkLocal(v6Gw)
put("ra_source_mac", mac ?: "(not EUI-64 — privacy/RFC 7217 address)")
if (mac != null) {
put("ra_source_vendor", Oui.vendor(mac) ?: "unknown OUI ${mac.take(8)}")
put("ra_source_mac_locally_administered", Oui.isLocallyAdministered(mac))
}
put("ra_source_reverse_dns", reverseDns(v6Gw))
}
// --- IPv4 gateway (usually the same box) ---
put("v4_gateway", v4Gw ?: "(none)")
if (v4Gw != null) {
put("v4_gateway_reverse_dns", reverseDns(v4Gw))
val gwSsdp = ssdp[v4Gw]
if (gwSsdp != null) {
put("upnp_server", gwSsdp.server)
put("upnp_location", gwSsdp.location)
gwSsdp.details?.let { d ->
put("upnp_manufacturer", d.manufacturer)
put("upnp_model", d.model)
put("upnp_friendly_name", d.friendlyName)
}
} else {
put("upnp", "no UPnP/SSDP response from the gateway")
}
}
}
}
}
// Every SSDP responder, so a rogue RA sender that is not the gateway can still be
// matched later (by IP now, by MAC once LLDP/mDNS inventories land).
putJsonArray("ssdp_responders") {
for ((ip, s) in ssdp) addJsonObject {
put("ip", ip); put("server", s.server); put("location", s.location)
s.details?.let {
put("manufacturer", it.manufacturer); put("model", it.model)
put("friendly_name", it.friendlyName)
}
}
}
}
val metrics = buildJsonObject {
put("ra_senders", raSenders)
put("ssdp_responders", ssdp.size)
}
val status = if (entries.isEmpty()) TestStatus.FAILED else TestStatus.OK
b.build(status, evidence = evidence, metrics = metrics)
}
/**
* Recovers the sender MAC from a modified-EUI-64 link-local address. The middle `ff:fe` marker
* must be present, and bit 1 of the first byte (U/L) is inverted back.
*/
private fun macFromEui64LinkLocal(addr: String): String? {
val bytes = runCatching { InetAddress.getByName(addr.substringBefore('%')).address }.getOrNull()
?: return null
if (bytes.size != 16) return null
// fe80::/10 with EUI-64: bytes 11,12 are 0xFF,0xFE
if ((bytes[11].toInt() and 0xFF) != 0xFF || (bytes[12].toInt() and 0xFF) != 0xFE) return null
val mac = byteArrayOf(
(bytes[8].toInt() xor 0x02).toByte(), bytes[9], bytes[10],
bytes[13], bytes[14], bytes[15],
)
return mac.joinToString(":") { "%02X".format(it) }
}
private fun reverseDns(ip: String): String = runCatching {
val clean = ip.substringBefore('%')
val host = InetAddress.getByName(clean).canonicalHostName
if (host == clean) "(none)" else host
}.getOrDefault("(none)")
private data class Ssdp(val server: String, val location: String, val details: Upnp?)
private data class Upnp(val manufacturer: String, val model: String, val friendlyName: String)
/** SSDP M-SEARCH for the InternetGatewayDevice + root devices; returns responder IP -> identity. */
private fun ssdpDiscover(): Map<String, Ssdp> {
val out = LinkedHashMap<String, Ssdp>()
val targets = listOf("urn:schemas-upnp-org:device:InternetGatewayDevice:1", "upnp:rootdevice")
runCatching {
DatagramSocket().use { sock ->
sock.soTimeout = 2500
sock.broadcast = true
for (st in targets) {
val msg = ("M-SEARCH * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"MX: 2\r\nST: $st\r\n\r\n").toByteArray()
sock.send(
DatagramPacket(msg, msg.size, InetSocketAddress("239.255.255.250", 1900))
)
}
val deadline = System.currentTimeMillis() + 3000
while (System.currentTimeMillis() < deadline) {
val buf = ByteArray(2048)
val dp = DatagramPacket(buf, buf.size)
try {
sock.receive(dp)
} catch (e: java.net.SocketTimeoutException) {
break
}
val ip = dp.address?.hostAddress ?: continue
if (out.containsKey(ip)) continue
val text = String(buf, 0, dp.length)
val server = header(text, "SERVER") ?: ""
val location = header(text, "LOCATION") ?: ""
out[ip] = Ssdp(server, location, location.takeIf { it.isNotBlank() }?.let(::fetchUpnp))
}
}
}
return out
}
private fun header(msg: String, name: String): String? =
msg.lineSequence().firstOrNull { it.startsWith("$name:", ignoreCase = true) }
?.substringAfter(':')?.trim()
/** Fetches the UPnP device description and pulls the identifying fields. */
private fun fetchUpnp(location: String): Upnp? = runCatching {
val conn = (URL(location).openConnection() as HttpURLConnection).apply {
connectTimeout = 2500; readTimeout = 2500; requestMethod = "GET"
}
val xml = conn.inputStream.bufferedReader().use { it.readText().take(20_000) }
conn.disconnect()
Upnp(
manufacturer = tag(xml, "manufacturer"),
model = listOf(tag(xml, "modelName"), tag(xml, "modelNumber"))
.filter { it.isNotBlank() }.joinToString(" "),
friendlyName = tag(xml, "friendlyName"),
)
}.getOrNull()
private fun tag(xml: String, name: String): String =
Regex("<$name>(.*?)</$name>", RegexOption.DOT_MATCHES_ALL)
.find(xml)?.groupValues?.get(1)?.trim() ?: ""
}
@@ -0,0 +1,209 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.probe
import android.content.Context
import app.echo_lot.measurement.Test
import app.echo_lot.measurement.TestStatus
import app.echo_lot.measurement.TestType
import app.echo_lot.measurement.Tier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.security.SecureRandom
/**
* nat.stun_5780 discovers this network's NAT behavior with plain RFC 5389/5780 STUN against the
* Echolot server (which advertises `stun-5780` when it has 2 same-family addresses).
*
* Three binding requests, and the comparison between them is the measurement:
* 1. **primary address, primary port** the reflexive (public) address and port.
* 2. **same socket, alternate address** (the server's OTHER-ADDRESS): if the mapped port is
* unchanged, the NAT keeps one mapping regardless of destination **endpoint-independent
* mapping** (good: peer-to-peer works). A different port address/port-dependent mapping
* (symmetric NAT: P2P needs relays).
* 3. **CHANGE-REQUEST(change-port)** asks the server to answer from a different port. A reply
* means the NAT/firewall accepts inbound from an endpoint it never sent to
* **endpoint-independent filtering**; silence means address/port-dependent filtering.
*
* Also detects being behind NAT at all (mapped address local address) the CGNAT/double-NAT
* signal when combined with the local address being private.
*/
class StunProbe(
private val serverHost: String,
private val stunPort: Int = 3478,
) : Probe {
override val type = TestType.NAT_STUN_5780
override val tier = Tier.APP
override val estimatedMs = 6_000L // three binding requests; the change-request usually times out (3s)
private companion object {
const val MAGIC_COOKIE = 0x2112A442.toInt()
const val TYPE_BINDING_REQUEST = 0x0001
const val TYPE_BINDING_SUCCESS = 0x0101
const val ATTR_CHANGE_REQUEST = 0x0003
const val ATTR_XOR_MAPPED = 0x0020
const val ATTR_OTHER_ADDRESS = 0x802C
const val CHANGE_PORT = 0x02
}
private data class Mapped(val addr: String, val port: Int)
private data class Reply(val mapped: Mapped?, val other: Mapped?)
override suspend fun run(ctx: Context, ids: ProbeIds): Test = withContext(Dispatchers.IO) {
val b = TestBuilder(type, tier, ids)
DatagramSocket().use { sock ->
sock.soTimeout = 3000
val localPort = sock.localPort
// 1) primary
val r1 = request(sock, serverHost, stunPort, change = 0)
if (r1?.mapped == null) {
return@withContext b.build(
TestStatus.FAILED,
evidence = buildJsonObject {
put("server", "$serverHost:$stunPort")
put("error", "no STUN binding response (blocked or server unreachable)")
},
)
}
// 2) same socket → the server's alternate address (OTHER-ADDRESS)
val alt = r1.other
val r2 = alt?.let { request(sock, it.addr, stunPort, change = 0) }
// 3) CHANGE-REQUEST(port) — tests inbound filtering
val r3 = request(sock, serverHost, stunPort, change = CHANGE_PORT)
val mappingBehavior = when {
alt == null -> "unknown (server has no OTHER-ADDRESS; only stun-basic)"
r2?.mapped == null -> "inconclusive (no reply from alternate address)"
r2.mapped.port == r1.mapped.port && r2.mapped.addr == r1.mapped.addr ->
"endpoint-independent (one mapping for all destinations — P2P friendly)"
else -> "address/port-dependent (symmetric NAT — P2P needs a relay)"
}
val filteringBehavior = when {
r3?.mapped != null -> "endpoint-independent (accepts inbound from an unseen endpoint)"
else -> "address/port-dependent (drops inbound from endpoints not contacted)"
}
// Behind NAT iff the reflexive address differs from this socket's local address.
// Port preservation is common and must NOT be read as "no NAT" — compare addresses.
val localAddr = localAddressOf(sock)
val behindNat = localAddr.isNotEmpty() && localAddr != r1.mapped.addr
val evidence: JsonObject = buildJsonObject {
put("server", "$serverHost:$stunPort")
put("local_addr", localAddr)
put("local_port", localPort)
put("mapped", "${r1.mapped.addr}:${r1.mapped.port}")
put("other_address", alt?.let { "${it.addr}:${it.port}" } ?: "")
put("mapped_via_alt", r2?.mapped?.let { "${it.addr}:${it.port}" } ?: "(no reply)")
put("change_port_reply", if (r3?.mapped != null) "received" else "none")
put("mapping_behavior", mappingBehavior)
put("filtering_behavior", filteringBehavior)
put("behind_nat", behindNat)
}
val metrics = buildJsonObject {
put("mapped_port", r1.mapped.port)
put("local_addr", localAddr)
put("local_port", localPort)
put("port_preserved", r1.mapped.port == localPort)
put("behind_nat", behindNat)
}
b.build(TestStatus.OK, evidence = evidence, metrics = metrics)
}
}
/**
* The address this socket actually sources from. An unbound DatagramSocket reports the
* wildcard ("::"/"0.0.0.0"), which says nothing, so probe the route to the server with a
* throwaway connected socket and read its local address.
*/
private fun localAddressOf(sock: DatagramSocket): String {
val direct = sock.localAddress?.hostAddress ?: ""
if (direct.isNotEmpty() && direct != "::" && direct != "0.0.0.0") return direct
return runCatching {
DatagramSocket().use { s ->
s.connect(InetSocketAddress(serverHost, stunPort))
s.localAddress?.hostAddress ?: ""
}
}.getOrDefault("")
}
/** One binding request; returns the parsed reply or null on timeout. */
private fun request(sock: DatagramSocket, host: String, port: Int, change: Int): Reply? {
val txid = ByteArray(12).also { SecureRandom().nextBytes(it) }
val attrs = if (change != 0) {
ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN).apply {
putShort(ATTR_CHANGE_REQUEST.toShort()); putShort(4)
put(0); put(0); put(0); put(change.toByte())
}.array()
} else ByteArray(0)
val msg = ByteBuffer.allocate(20 + attrs.size).order(ByteOrder.BIG_ENDIAN)
msg.putShort(TYPE_BINDING_REQUEST.toShort())
msg.putShort(attrs.size.toShort())
msg.putInt(MAGIC_COOKIE)
msg.put(txid)
msg.put(attrs)
val bytes = msg.array()
return try {
sock.send(DatagramPacket(bytes, bytes.size, InetSocketAddress(host, port)))
val buf = ByteArray(1500)
val dp = DatagramPacket(buf, buf.size)
sock.receive(dp)
parse(buf, dp.length, txid)
} catch (e: Throwable) {
null
}
}
private fun parse(data: ByteArray, len: Int, txid: ByteArray): Reply? {
if (len < 20) return null
val bb = ByteBuffer.wrap(data, 0, len).order(ByteOrder.BIG_ENDIAN)
if ((bb.getShort(0).toInt() and 0xFFFF) != TYPE_BINDING_SUCCESS) return null
val msgLen = bb.getShort(2).toInt() and 0xFFFF
var mapped: Mapped? = null
var other: Mapped? = null
var off = 20
while (off + 4 <= 20 + msgLen && off + 4 <= len) {
val at = bb.getShort(off).toInt() and 0xFFFF
val al = bb.getShort(off + 2).toInt() and 0xFFFF
if (off + 4 + al > len) break
when (at) {
ATTR_XOR_MAPPED -> mapped = parseAddr(data, off + 4, al, txid, xor = true)
ATTR_OTHER_ADDRESS -> other = parseAddr(data, off + 4, al, txid, xor = false)
}
off += 4 + al + ((4 - al % 4) % 4)
}
return Reply(mapped, other)
}
/** RFC 5389 address attribute; XOR-MAPPED needs de-XORing with the cookie + txid. */
private fun parseAddr(data: ByteArray, off: Int, len: Int, txid: ByteArray, xor: Boolean): Mapped? {
if (len < 8) return null
val v = data.copyOfRange(off, off + len)
val family = v[1].toInt() and 0xFF
var port = ((v[2].toInt() and 0xFF) shl 8) or (v[3].toInt() and 0xFF)
if (xor) port = port xor ((MAGIC_COOKIE ushr 16) and 0xFFFF)
val key = ByteBuffer.allocate(16).order(ByteOrder.BIG_ENDIAN)
.putInt(MAGIC_COOKIE).put(txid).array()
return if (family == 0x01) {
val a = ByteArray(4) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
Mapped(a.joinToString(".") { (it.toInt() and 0xFF).toString() }, port)
} else {
if (len < 20) return null
val a = ByteArray(16) { i -> if (xor) (v[4 + i].toInt() xor key[i].toInt()).toByte() else v[4 + i] }
Mapped(java.net.InetAddress.getByAddress(a).hostAddress ?: "", port)
}
}
}
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.serialization)
}
// Pure Kotlin/JVM: the client half of probe-protocol.md. No Android deps, so
// the Android app modules can depend on it and it stays unit-testable (incl.
// live integration tests) on any JDK. Crypto, HTTP and UDP come from the JDK
// (javax.crypto, java.net.http, java.net) — only JSON needs a library.
dependencies {
implementation(libs.kotlinx.serialization.json)
testImplementation(kotlin("test"))
}
kotlin {
// Build with the available JDK (Android Studio's JBR is 21) but emit
// Java-17 bytecode so the Android app modules can consume this library.
jvmToolchain(21)
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.test {
useJUnitPlatform()
// The live end-to-end test against a real server only runs when
// ECHOLOT_LIVE_URL is set; otherwise it self-skips (see LiveServerTest).
listOf("ECHOLOT_LIVE_URL", "ECHOLOT_LIVE_PIN", "ECHOLOT_LIVE_CRED",
"ECHOLOT_LIVE_UDP", "ECHOLOT_LIVE_TARGET").forEach { k ->
System.getenv(k)?.let { environment(k, it) }
}
}
@@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
/**
* Version compatibility, the client side of the same question the server asks about us.
*
* Versions are SemVer, but what is really being checked is whether the peer speaks a wire protocol
* and schema this build understands; the version is a proxy, and it only works because the
* breaking axis is bumped when the contract changes. Bounds therefore sit at breaking boundaries
* rather than at individual releases a server patch release must never make the app refuse to
* talk to it.
*
* Mirrors `server/internal/compat`. Two implementations of one rule is a duplication worth
* accepting: each side must be able to state and enforce its own limits without asking the other,
* which is the entire point of a compatibility check.
*/
data class SemVer(
val major: Int,
val minor: Int,
val patch: Int,
val pre: String = "",
) : Comparable<SemVer> {
override fun compareTo(other: SemVer): Int {
(major - other.major).let { if (it != 0) return it.coerceIn(-1, 1) }
(minor - other.minor).let { if (it != 0) return it.coerceIn(-1, 1) }
(patch - other.patch).let { if (it != 0) return it.coerceIn(-1, 1) }
// A pre-release sorts below the same version without one (SemVer §11).
return when {
pre == other.pre -> 0
pre.isEmpty() -> 1
other.pre.isEmpty() -> -1
else -> pre.compareTo(other.pre).coerceIn(-1, 1)
}
}
override fun toString(): String = "$major.$minor.$patch" + if (pre.isEmpty()) "" else "-$pre"
/**
* The first version that may break compatibility with this one. Below 1.0.0 the minor is the
* breaking axis (SemVer §4), so 0.4.2's next break is 0.5.0 treating it as 1.0.0 would let
* this build accept a peer it cannot actually talk to.
*/
fun nextBreaking(): SemVer =
if (major == 0) SemVer(0, minor + 1, 0) else SemVer(major + 1, 0, 0)
companion object {
/** Accepts "1.2.3", "v1.2.3" and namespaced tags like "server-v1.2.3". Null if unusable. */
fun parse(raw: String?): SemVer? {
var s = raw?.trim().orEmpty()
if (s.isEmpty()) return null
// Strip a tag prefix ending in "v", guarded on the prefix having no digits so a
// pre-release identifier containing a "v" is left alone.
val v = s.lastIndexOf('v')
if (v >= 0 && v + 1 < s.length && s[v + 1].isDigit() && s.take(v).none { it.isDigit() }) {
s = s.substring(v + 1)
}
s = s.substringBefore('+')
val pre = s.substringAfter('-', "")
val core = s.substringBefore('-')
val parts = core.split(".")
if (parts.size != 3) return null
val nums = parts.map { it.toIntOrNull() ?: return null }
if (nums.any { it < 0 }) return null
return SemVer(nums[0], nums[1], nums[2], pre)
}
}
}
/** [min, max): minimum inclusive, maximum exclusive. A null max is unbounded. */
data class VersionRange(val min: SemVer, val max: SemVer? = null) {
operator fun contains(v: SemVer): Boolean = v >= min && (max == null || v < max)
override fun toString(): String = ">= $min" + (max?.let { ", < $it" } ?: "")
companion object {
fun of(min: String, max: String?): VersionRange {
val lo = requireNotNull(SemVer.parse(min)) { "bad minimum version: $min" }
val hi = max?.takeIf { it.isNotBlank() }?.let {
requireNotNull(SemVer.parse(it)) { "bad maximum version: $it" }
}
require(hi == null || hi > lo) { "maximum $max is not above minimum $min" }
return VersionRange(lo, hi)
}
}
}
/**
* What this build of the app requires of a server, and what it tells servers about itself.
*
* Two separate questions, deliberately not conflated:
*
* - **Protocol version** can these builds talk at all? This is the correctness axis, and its
* breaking boundary is enforced strictly.
* - **Release-version window** should they, per policy? A coarse safety net over the peer's
* SemVer, with bounds at breaking boundaries so a patch release never strands anyone. Both
* sides publish their own, and the operator can tighten the server's.
*
* `MIN_SERVER` is 0.4.2 for a concrete reason, not caution: below it a multi-homed server sent
* granted traffic from an address the session never used, so every downstream packet was dropped
* in transit and reported as 100 % downstream loss. A confidently wrong measurement is worse than
* a refused one, so talking to those builds is not something to allow "just in case".
*/
object Compat {
/** Header the app sets on every control-plane request. */
const val APP_VERSION_HEADER = "X-Echolot-App-Version"
/** The wire contract (probe-protocol.md) this build implements. */
const val PROTOCOL_VERSION = "1.0.0"
const val MIN_SERVER = "0.4.2"
/** Exclusive. The next breaking series is refused until this app is taught about it. */
const val MAX_SERVER = "1.0.0"
val serverRange: VersionRange = VersionRange.of(MIN_SERVER, MAX_SERVER)
enum class Verdict { OK, PROTOCOL_MISMATCH, SERVER_TOO_OLD, SERVER_TOO_NEW, APP_REFUSED, UNKNOWN }
/**
* The result of checking a server, with a message written for the person holding the phone.
* [usable] is what callers branch on; [message] is what they show.
*/
data class Result(val verdict: Verdict, val message: String?) {
val usable: Boolean get() = verdict == Verdict.OK || verdict == Verdict.UNKNOWN
}
/**
* Checks a profile both ways: is the server within our range, and are we within the server's.
*
* Asking both is the point of advertising the window in the profile. Discovering that the
* server will refuse us only when a measurement fails halfway through is a much worse
* experience than being told before the run starts.
*/
fun check(profile: Profile, appVersion: String): Result {
// The protocol version is the axis that decides whether these two builds *can* talk;
// the release-version window below is the operator's policy about whether they *may*.
// Checking the real thing first means a mismatch is reported as what it is.
val ours = SemVer.parse(PROTOCOL_VERSION)!!
val theirs = SemVer.parse(profile.compat.protocolVersion)
if (theirs != null && theirs >= ours.nextBreaking()) {
return Result(
Verdict.PROTOCOL_MISMATCH,
"This server speaks probe protocol ${profile.compat.protocolVersion}; this app " +
"speaks $PROTOCOL_VERSION and does not understand that revision. Update the app.",
)
}
if (theirs != null && ours >= theirs.nextBreaking()) {
return Result(
Verdict.PROTOCOL_MISMATCH,
"This server speaks probe protocol ${profile.compat.protocolVersion}, which this " +
"app ($PROTOCOL_VERSION) has moved past. Update the server.",
)
}
val server = SemVer.parse(profile.serverVersion)
?: return Result(
Verdict.UNKNOWN,
"Server did not report a usable version (\"${profile.serverVersion}\") — " +
"continuing without a compatibility check.",
)
if (server < serverRange.min) {
return Result(
Verdict.SERVER_TOO_OLD,
"This server runs $server; Echolot needs $serverRange. " +
"Measurements against older servers can be wrong rather than merely missing, " +
"so update the server.",
)
}
if (serverRange.max != null && server >= serverRange.max) {
return Result(
Verdict.SERVER_TOO_NEW,
"This server runs $server, which is newer than this app understands " +
"($serverRange). Update the app.",
)
}
// And the server's own view of us.
val app = SemVer.parse(appVersion)
val serverWantsMin = SemVer.parse(profile.compat.appMin)
val serverWantsMax = SemVer.parse(profile.compat.appMax)
if (app != null && serverWantsMin != null) {
if (app < serverWantsMin) {
return Result(
Verdict.APP_REFUSED,
"This server only accepts Echolot $serverWantsMin or newer; this app is " +
"$app. Update the app.",
)
}
if (serverWantsMax != null && app >= serverWantsMax) {
return Result(
Verdict.APP_REFUSED,
"This server refuses Echolot $serverWantsMax and newer; this app is $app. " +
"Use an older app, or a server that has caught up.",
)
}
}
return Result(Verdict.OK, null)
}
}
@@ -0,0 +1,197 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.protocol
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.net.URL
import javax.net.ssl.HttpsURLConnection
/** The server declined to store this run, per the operator's policy. Not a transport failure. */
class UploadRefused(message: String) : Exception(message)
/**
* The server refused this app's version. Distinct from a transport failure and from an auth
* failure: nothing about the request was wrong, the two builds simply do not go together, and the
* message says which versions do.
*/
class VersionRefused(message: String) : Exception(message)
/**
* 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)
* @param appVersion this build's SemVer, sent on every request so the server can refuse a build
* it cannot serve *before* a measurement half-runs (BuildConfig.VERSION_NAME).
*/
class ControlClient(
private val controlUrl: String,
pins: Set<String>,
private val appVersion: 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") }
if (appVersion.isNotBlank()) conn.setRequestProperty(Compat.APP_VERSION_HEADER, appVersion)
return conn
}
/**
* 426 is the server saying "your version, not your request". Raised as a distinct exception
* from every call site so callers never report it as a network error the whole value of the
* check is that the failure is legible.
*/
private fun checkVersion(conn: HttpsURLConnection, body: String) {
if (conn.responseCode == 426) throw VersionRefused(extractError(body) ?: body.take(200))
}
/**
* Pulls the "error" string out of a JSON body.
*
* Parsed rather than pattern-matched: an encoder may legitimately escape characters in the
* message (Go escapes ">" by default), and a regex hands the user "needs \u003e= 0.2.0".
* The parser knows how to undo every escape; a regex would have to be taught each one.
*/
private fun extractError(body: String): String? = runCatching {
json.parseToJsonElement(body).jsonObject["error"]?.jsonPrimitive?.content
}.getOrNull()
/**
* Reads the response body, and turns a 426 into [VersionRefused] first.
*
* Every call goes through here, so the version check cannot be forgotten at a new call site
* the alternative (a check per method) is exactly the kind of thing that gets missed once and
* then reports "upload failed: 426" to a user for a year.
*/
private fun body(conn: HttpsURLConnection): String {
val stream = if (conn.responseCode in 200..299) conn.inputStream else conn.errorStream
val text = stream?.bufferedReader()?.use { it.readText() } ?: ""
checkVersion(conn, text)
return text
}
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 "{}")
val text = body(conn) // reads and raises VersionRefused on 426
check(conn.responseCode == 201) { "enroll failed: ${conn.responseCode} $text" }
return json.decodeFromString(EnrollResponse.serializer(), text)
}
fun profile(credential: String): Profile {
val conn = open("/v1/profile", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "profile failed: ${conn.responseCode} $text" }
return json.decodeFromString(Profile.serializer(), text)
}
fun createSession(credential: String, target: String): SessionResponse {
val conn = open("/v1/sessions", "POST", credential)
writeJson(conn, """{"target":${jstr(target)}}""")
val text = body(conn)
check(conn.responseCode == 201) { "session failed: ${conn.responseCode} $text" }
return json.decodeFromString(SessionResponse.serializer(), text)
}
/**
* Requests a §5 action. The server creates an asymmetric grant for the granted ones
* (downtrain / big_send) and starts sending toward the session's observed data-plane source,
* so the caller must already have sent at least one ECHO. Returns the raw JSON reply.
*/
fun action(credential: String, sessionId: String, bodyJson: String): String {
val conn = open("/v1/sessions/$sessionId/actions", "POST", credential)
writeJson(conn, bodyJson)
val body = body(conn)
check(conn.responseCode in 200..299) { "action failed: ${conn.responseCode} $body" }
return body
}
/**
* Uploads one measurement document. The body is sent exactly as given whatever the
* anonymizer produced is what the server stores, so what the user was shown is what left
* the device. Returns the server's index entry as raw JSON.
*
* A refusal is not an error condition to retry: 403 means the operator's policy says no
* (uploads off, accounts required, or not anonymized enough), so it is surfaced as
* [UploadRefused] for the caller to show rather than swallow.
*/
fun uploadRun(credential: String, documentJson: String): String {
val conn = open("/v1/runs", "POST", credential)
writeJson(conn, documentJson)
val body = body(conn)
when (conn.responseCode) {
in 200..299 -> return body
403 -> throw UploadRefused(extractError(body) ?: body.take(200))
413 -> throw UploadRefused("run is larger than this server accepts: $body")
else -> error("upload failed: ${conn.responseCode} $body")
}
}
/** Lists this device's runs stored on the server. */
fun listRuns(credential: String): String {
val conn = open("/v1/runs", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "list runs failed: ${conn.responseCode} $text" }
return text
}
fun getRun(credential: String, runId: String): String {
val conn = open("/v1/runs/$runId", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "get run failed: ${conn.responseCode} $text" }
return text
}
fun deleteRun(credential: String, runId: String) {
open("/v1/runs/$runId", "DELETE", credential).responseCode
}
fun observations(credential: String, sessionId: String): String {
val conn = open("/v1/sessions/$sessionId/observations", "GET", credential)
val text = body(conn)
check(conn.responseCode == 200) { "observations failed: ${conn.responseCode} $text" }
return text
}
fun deleteSession(credential: String, sessionId: String) {
open("/v1/sessions/$sessionId", "DELETE", credential).responseCode
}
}

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