On-device verification found both.
safeDrawingPadding() was on the run screen but not on Settings or History -
they were added later and never got it - so "< Back Settings" sat under the
status-bar clock. The same fault the run screen had already fixed, reintroduced
by new code that did not know about it.
Settings also read "0 run(s), 23 kB stored": the count came from
UiState.history, which stays empty until the History screen has been opened,
while the size read the archive directly. Two sources for one fact; the count
now reads the archive too.
Verified on a OnePlus 15 (A16): header clears the status bar, count reads
"1 run(s), 23 kB stored".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting out to build the machine-readable schema, the first step was checking
whether the anonymizer covers the fields the schema declares sensitive. It did
not, and five identifying values were going out at the `balanced` level:
networks[].link.addresses[].addr the device's own global IPv6 address
networks[].link.routes[].gateway the ISP allocation
networks[].link.dns.servers[] the configured resolver
private_dns_hostname an internal hostname
search_domains[] the internal domain
The settings screen describes that level as pseudonymizing addresses.
Root cause: classification keyed on field names, and the schema's actual names
were never added to the table. Every existing test passed, because each checked
a field somebody had remembered to write a case for - an unfalsifiable design
for a privacy control.
So beyond adding the names, classification now falls back to the *value* when
the name is unknown: anything shaped like an IPv4/IPv6 address or a MAC is
treated as one. Hostnames deliberately are not inferred by shape, since
train.udp_updown is indistinguishable from a domain and mangling a test type
would corrupt the document to protect nothing.
LeakTest is the guard, and is written to fail for fields nobody thought of: it
plants identifying values wherever one can occur and asserts none survive. It
also pins that RFC1918 addresses stay readable, so it cannot pass by
over-redacting. Route prefixes and :: needed care - 0.0.0.0/0 must stay itself
or a routing table becomes unreadable for no privacy gain.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A finding code is the stable half of a result - what a dashboard groups by and
what someone greps a year of archived runs for. That only holds if a code means
exactly one thing forever, which fifteen ad-hoc string literals cannot promise.
By the time this was written the failure had happened twice:
- Two emitters independently produced connectivity.downstream_loss and
connectivity.loss_downstream for the same claim. Nothing objected. Anyone
aggregating either would have silently seen half their data.
- Two codes sat under nat.* while being declared Category.CONNECTIVITY.
nat.udp_unreachable is not about NAT, and the prefix decides the category,
which decides which verdict light the finding rolls up into. Renamed while
that is still cheap.
Codes are now typed FindingSpecs carrying category and default severity;
emitters reference the spec rather than retyping the string, so a typo is a
compile error and two call sites cannot disagree about a finding's category.
docs/findings-registry.md is the contract and a test reads it, failing when the
document and the code disagree on which codes exist or how severe they are.
Documentation that drifts from its implementation is worse than none, because it
still looks authoritative. The check reads table rows only, so the prose can go
on explaining which codes were retired and why.
Closes open item 1 of measurement-schema.md section 9.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 5-second run at 50 Mbps moves ~30 MB. On a metered connection that is the
user's money, and a measurement tool that spends it unasked is not one people
keep installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The live throughput test found it: a 3-second run delivered 104 packets and
stopped after 50 milliseconds.
The rate check exempted the first 50 ms entirely, meaning to be lenient at
startup. The effect was the opposite. A sender could dump an unbounded burst
into that free window, and the instant the check switched on it compared those
bytes against 50 ms worth of allowance and refused everything until real time
caught up. Every short test passed — downtrain sends 50 packets, big_send seven
— and every sustained send died about fifty milliseconds in.
A token bucket (allowance = burst + rate x elapsed) has no such cliff; it is
smooth from t=0. The burst is 100 ms of the allowed rate, floored at one
ordinary datagram so a single packet is never refused outright. The floor is
deliberately one datagram: at 8 kbps a 64 KB floor would be sixty-four seconds'
worth, which is precisely the instant dump the ceiling exists to prevent. The
existing rate test caught that when I first tried it, and it was right.
Second half of the same bug: callers treated any refusal as terminal. TryAllow
now says why, so a sender can pace through a transient "too fast just now" and
still stop dead on a spent budget or an expired grant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A throughput number reports the smallest limit on the path, and the sender's own
ceiling is one of the candidates. If the server was asked for 50 Mbps and 50
Mbps arrived, the network was never the constraint and "50 Mbps" says nothing
about it. So the result always carries limited_by and measures_network, and a
finding is raised only when the path is actually implicated.
Loss is computed against the *sender's* count, not the requested rate: the
server reports what it put on the wire, and the gap is the loss. A receiver
alone cannot tell "the network dropped it" from "the sender never sent it", and
guessing turns a healthy server-side limit into a phantom network fault. The
count is stored per action, not per packet — half a million packets of structs
would turn a measurement into memory exhaustion.
Sending is paced rather than flat out. An unpaced burst measures the server's
NIC and the first queue it meets, then collapses into loss that reads as a
network fault. The schedule is absolute rather than sleep-per-packet, which
would accumulate scheduler error and drift the rate down over a ten-second run.
Throughput gets its own grant budget sized from the request, so every other
action stays bounded at 8 MiB. When the byte cap binds before the clock does,
the *duration* is shortened and reported, rather than the run being truncated
halfway: promising thirty seconds and delivering twenty-one is the same
information with a surprise attached, and it keeps "the clock ended the run" as
the normal case — the only case where the rate is a clean property of the path.
That last behaviour came out of a test that failed honestly: 30 s at 100 Mbps
needs 375 MB against a 256 MB cap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
"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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>