Commit Graph
44 Commits
Author SHA1 Message Date
mrambossekandClaude Opus 5 ab6e278272 app: an IMS network is not a blocked measurement
rmnet_data1 on the OnePlus is the carrier's IMS/VoLTE network: it carries
IMS&MMTEL but neither INTERNET nor NOT_RESTRICTED, so binding needs
CONNECTIVITY_USE_RESTRICTED_NETWORKS - signature-level, unobtainable.
EPERM there is permanent and says nothing about any network's health, but
the constraint detector counted it, so a phone with VoLTE reported
'measurement blocked' on every run and every verdict came out
INCONCLUSIVE. Capabilities now decide: networks an app may never bind are
recorded as app_usable:false in networks[] and skipped, rather than
reported as something the run failed to do.

Also retires the 'while it tears down' wording, which was a guess that
turned out to be wrong - the refusal outlasts the VPN indefinitely, so
the text now names the VPN as the usual cause without asserting a
timeline it cannot know.

App version 0.2.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:11:08 +02:00
mrambossekandClaude Opus 5 c5f3c2e7af app: learn probe durations per device; stop claiming VPN after teardown
server-release / image (push) Successful in 15s
server-release / release (push) Successful in 31s
The ETA table was fixed at compile time, but real durations are a property
of this phone and the network it stands in - ICMPv6 answers in
milliseconds where IPv6 works and waits out its timeout where it does
not. Estimates now prefer an EMA (70/30) of what this device actually
measured, seeded by the old constants on first run.

VPN detection had two lies in it, both found on hardware: a disconnected
tunnel lingers in allNetworks while tearing down, so 'VPN active' is now
judged from the ACTIVE network only; and any bind failure counted as
'per-network blocked', so a network dying mid-run flipped a healthy run
to INCONCLUSIVE - now only a genuine EPERM refusal counts. Banner and
finding split three ways (VPN + blocked / blocked only / VPN only) so the
app never names a VPN the user just turned off.

App version 0.2.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:41:10 +02:00
mrambossekandClaude Opus 5 3214cc877a app: parse the Shizuku dumps into link.ra_source and sec.arp_watch
Pure-Kotlin parsers over the captures the battery already makes, tested
against the real archived dumps from both devices - including the
Lenovo's 1000000015 route tables (table ids stay strings), the OnePlus's
stray uid=2000 prefix and mid-line hoplimit, and an ip_monitor that only
ever said EXEC_TIMEOUT. ShizukuProbe now returns three tests; a failed
capture is SKIPPED with its reason so no test type silently vanishes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:34:52 +02:00
mrambossekandClaude Opus 5 f6849f8e6a app: fold the prober's validated capabilities into core-probe
traceroute.udp4 lands as TracerouteProbe: ICMP time-exceeded read off the
socket error queue via Os.recvmsg(MSG_ERRQUEUE) through the reflection
facade the prober validated on both devices - no root, no raw socket, and
the C-over-JNI shim stays dead. Emits the schema's TracerouteEvidence with
rtt_ns. OsAbi carries the hardcoded sockopt ABI numbers across, including
the measured fact that Os.getsockoptInt exists on neither device, so PMTU
must come from the errqueue, never getsockopt(IP_MTU).

local.mdns_inventory lands as MdnsInventoryProbe with both hardware-bought
lessons intact: the meta-query lies (0 results beside live services on both
devices), and 4s of listening misses what 10s catches.

App version 0.2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 12:39:52 +02:00
mrambossekandClaude Opus 5 20cfecf566 app: name what a VPN blocked, and prove v6 broken before saying so
Constraints are detected up front (one throwaway bind per network) and land
in run.constraints, a measurement.vpn_constrained finding, the $7.3 verdict
(INCONCLUSIVE outright) and a banner on the run screen - a VPN'd run looked
exactly like a clean run of a healthy network before this.

v6.broken returns to the registry now that it can be earned: V6ConnectProbe
(v6.brokenness) makes a real TCP connection over IPv6 to the enrolled
server, and only both transports failing on a network that advertises IPv6
justifies the claim. TCP succeeding turns the finding into 'ICMPv6 is
filtered, IPv6 works' at high confidence instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 12:31:44 +02:00
mrambossekandClaude Opus 5 d5b1bab577 app: stop the autorun upload pointing at a listener that is gone
"upload failed: HTTP 400 client sent an HTTP request to an HTTPS server"
is a TLS listener rejecting cleartext, and the cleartext was ours:
REPORT_UPLOAD_URL was http://89.185.109.150:443/report, which used to be
the adb-beacon receiver holding 0.0.0.0:443 in plaintext. Disabling that
receiver and giving 443 to echolot-server left this posting plain HTTP at
a TLS port.

Blanked rather than repointed. The endpoint existed so an unattended run
could be collected without adb, and autorun reports are now read straight
off the device with `run-as cat` — so it was buying nothing and emitting
an alarming error for a debugging convenience. Deliberately not aimed at
/v1/runs either: that is the consent-gated upload, and a debugging
shortcut must not be able to satisfy it by accident.

The message says what happened instead of implying something broke.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:36:49 +02:00
mrambossekandClaude Opus 5 a17c3fd9e6 app: catch a search domain that swallows DNS queries
A tablet on a healthy network could not resolve anything. The DNS server
answered the bare name correctly — NOERROR, two records, A and AAAA, with
and without EDNS0 — so the earlier finding blamed the device's resolver.
It was wrong. The network advertised hudelist.local as a search domain and
the server silently dropped every query under it: not NXDOMAIN, nothing at
all. Resolvers append search domains, so they waited for a reply that was
never coming.

Silence is the part that makes this vicious. A negative answer moves a
resolver on; no answer looks like packet loss, so it retries, and some
give up on the lookup entirely. It also explains how two devices on one
network can disagree about whether DNS works — the phone tried the plain
name first and never noticed.

The probe now asks about a nonce name under each advertised search domain,
where the wanted answer is NXDOMAIN and only silence is a fault. The
finding is ordered ahead of dns.system_resolver_broken so the two cannot
both fire: without that, this exact network gets told its device is
broken.

Severity follows the harm rather than the shape. HIGH when resolution is
actually failing, MEDIUM when the domain is a black hole but this resolver
happens to try the plain name first — calling that HIGH would be crying
wolf on a network that works. The message names the fix and notes that
.local is reserved for mDNS by RFC 6762 and widely dropped by design,
while home.arpa (RFC 8375) is the name reserved for this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:29:04 +02:00
mrambossekandClaude Opus 5 cfa58e8d60 app: a DNS reply is not a DNS answer
The probe counted any well-formed packet from the server as "the server
answers", checking only the transaction id and a minimum length. REFUSED
and SERVFAIL are well-formed packets. So a server actively refusing this
client would have been reported as healthy, and the finding — whose whole
output is "the network is fine, your device is not" — would have pointed
confidently at the wrong component.

It now requires rcode 0 and at least one record, and reports a refusal as
what it is: a working server saying no, which points back at the network.
The rcode is named rather than numbered, because "REFUSED" is a fact an
operator can act on and "rcode 5" is a lookup.

Caught by decoding what fmr's router actually replied — ab cd 81 80 00 01
00 02, NOERROR with two answers — after realising the earlier check only
counted bytes. The reply was genuinely good, so the finding on the tablet
stands; the check was wrong regardless.

The advice is broader too. That tablet's fault survived a reboot, which
makes "toggle wifi and it clears" wrong as a flat claim: it now says what
to look at when a restart does not fix it — something on the device
filtering DNS, or a per-client rule on the router.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:10:36 +02:00
mrambossekandClaude Opus 5 2ed4d1f478 Reach the server by address when its name will not resolve
A measurement tool that cannot report from a broken network is useless
exactly when it matters, and a wedged resolver is one of the faults this
app is built to find — it should not also be the thing that stops the
finding being delivered. The profile already carries the server's
addresses; they are now kept and used when the name fails.

Safe because the pin is the trust and the name is not part of it: the
server presents the same certificate however it was reached, and a wrong
address fails the pin like anything else. Only the primaries are cached —
the alternate pair exists for NAT behaviour discovery and does not carry
the control plane, so falling back to one would fail for a second,
unrelated reason.

Substituted only when the name genuinely does not resolve, and only after
checking the candidate answers on the port: on a v4-only network a v6
address would otherwise be chosen and fail slowly, which is the wrong
answer delivered late.

The server had to meet it halfway. Sharing 443 by SNI meant a client
arriving by IP sent no server name and got the Let's Encrypt certificate,
failing the pin. A numeric host — or no SNI at all — now selects the
pinned certificate and routes to the control plane. That is sound because
the admin UI is only ever reached by name: browsers always send SNI, and
nobody bookmarks an IP for a site with a CA-issued certificate.

Verified against fmr: by IP on both families the served pin is the
control one and /v1/profile answers 401, while fmr.echo-lot.app still
serves the Let's Encrypt certificate and the admin UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 10:03:14 +02:00
mrambossekandClaude Opus 5 d65dbbc75a app: tell a broken device resolver apart from a broken network
Diagnosing a tablet that claimed "no internet" took twenty adb commands
to establish something the app should have said in one run: ping to
1.1.1.1 worked, the configured DNS server answered a raw UDP query in
65 bytes, and Android still could not resolve a hostname. The network was
fine; netd had wedged.

Those two failures look identical to a user and want opposite responses —
"look at your router" against "toggle your wifi" — so dns.resolver asks
the network's own servers directly and compares the answer against what
the platform returns for the same name. The query is hand-rolled over a
plain DatagramSocket on purpose: anything routed through a resolver API
would inherit the very fault being looked for.

dns.system_resolver_broken fires only on the pairing that is otherwise
unattributable: server answered, platform did not. Per network, because a
phone can have wedged wifi and working cellular at once.

Also records Android's own verdict per network — validated, captive
portal, partial connectivity — which the app reproduced with its own HTTP
probes but never stored. It is free, it is what the user sees in the
status bar, and its disagreement with our measurements is exactly what
identified the tablet. NET_CAPABILITY_PARTIAL_CONNECTIVITY is @SystemApi
so the constant is inlined with its rationale, in the manner of OsAbi.kt,
and read defensively enough to report unknown rather than false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 09:44:49 +02:00
mrambossekandClaude Opus 5 5b02d40802 app: notice when Shizuku is authorised
The banner watched only for the binder arriving or dying, and granting
permission does neither. So after the user tapped the banner, answered
the dialog and came back, it still read "running but not authorised" —
wrong at precisely the moment they were looking for confirmation that it
had worked.

Two listeners were missing, because there are two ways this changes.
Answering our own request now fires OnRequestPermissionResultListener.
That is not enough on its own: permission can equally be granted inside
Shizuku's own app, and Shizuku started or stopped there, none of which
calls back into this process — so the state is re-read whenever the
screen comes forward, which is the only thing that covers every route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:37:45 +02:00
mrambossekandClaude Opus 5 416b783647 app: lay the server facts out in real columns
The block was rendered as one padded string, which only lines up in a
monospaced font — and the monospace never took, so the values sat at
ragged offsets and the point of the list was lost. Padding text to fake a
table makes the layout depend on a typeface decision made elsewhere.

It is a label column of fixed width and a value column that takes the
rest, so the addresses align whatever the font does and a long value wraps
inside its own column instead of under the labels. The capability list
goes back to plain comma-separated text: hand-wrapping it at three per
line was working around the same missing alignment.

Placement too. The facts now sit under the "Check server" button that
fetches them, rather than among the input fields, and the sentence
explaining the endpoint sits directly under the URL field it describes —
it had ended up orphaned between the two, reading as a comment on nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:24:00 +02:00
mrambossekandClaude Opus 5 c4f2a10790 app: show what the server reports as facts, not as inputs
The settings card offered three editable boxes and said nothing about the
server itself — which addresses a test will actually use, on which ports,
what it can measure. That is the part a person checks before trusting a
result, and "which address did this come from" is precisely the question
a report leaves open.

The server now publishes it. The profile's targets carried one IPv4 and a
TODO; it reports both families and both alternates, derived from the UDP
listen spec rather than configured separately, so the list cannot drift
from what is actually bound. No reservation means no alternate is
claimed: announcing a second address as the RFC 5780 alternate when none
was set aside would promise a redirect the server will not send.

The app renders them read-only, in a panel visibly distinct from the
fields above. An editable box that changes nothing is worse than no box,
and these are facts to read rather than settings to apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:14:42 +02:00
mrambossekandClaude Opus 5 fe4ec23ba1 app: show the server by the name its operator handed out
The Server URL field showed the endpoint the app dials, which after
discovery is not the name anyone was given — enrolling against
fmr.echo-lot.app left the settings reading fmr-1.echo-lot.app, with no
explanation of where the -1 came from.

It shows the public name now. The endpoint is not hidden, just demoted to
a line beneath that says where the connection actually goes and why the
two differ: a network engineer debugging a failed connection wants that,
and burying it would trade one confusion for another.

Typing a URL by hand sets both, since there is no discovery to consult in
that case — setting only the public one would leave the app still dialling
the previous server, which is the kind of half-applied change that fails
much later and somewhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:07:55 +02:00
mrambossekandClaude Opus 5 f6e093944c build: document the poisoned build cache; compare like with like
An entire module was missing from the APK. The app died with
ClassNotFoundException for app.echo_lot.protocol.EnrollmentLink while the
build was green, the module's jar was correct, and :app:dependencies
listed it on debugRuntimeClasspath — its code simply never reached AGP's
intermediates.

The cause was a poisoned Gradle build cache entry, which is why nothing
obvious fixed it: clean, rm -rf */build and --rerun-tasks all leave the
build cache alone. Only --no-build-cache did. Every app build made in this
session shipped without core-protocol, so enrolment, sign-in and upload
would all have crashed identically; several hours of "the tap does
nothing" were this, misread as a UI problem.

CLAUDE.md now carries the symptom, the fix, and the verification —
grepping the dex for a string literal only that module defines, because
grepping for a class *name* proves nothing: callers carry the name as a
reference whether or not the class is packaged. That false check is what
let me believe an earlier rebuild had fixed it.

Also: the enrolment dialog compared the stored endpoint against the link's
public URL, so re-enrolling with the same server announced itself as a
move to a different one. Those are deliberately different strings now that
discovery exists; the comparison uses the public name on both sides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 08:01:58 +02:00
mrambossekandClaude Opus 5 bc384531e6 app: enrollment feedback beside its own button
The enrollment result was written to the same status line as everything
else, which renders at the far end of the server card below three text
fields — and on a fresh install renders nowhere at all, because that line
only appears once a run exists. So enrolling looked identical whether it
worked or not.

It has its own line now, directly under the Enroll button that caused it,
and its own state rather than sharing one with "Check server": two
actions, two results.

The server fields were also re-read the instant the button was pressed,
before the enrollment coroutine had done anything, so they showed the
previous server's values. They now refresh when the result lands, which
is the point at which there is something new to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:48:36 +02:00
mrambossekandClaude Opus 5 082a2314ef protocol: enrollment links carry the public name, not the endpoint
Sharing port 443 between the admin UI and the control plane forces two
hostnames — one port and one name is one certificate, and the two need
different ones. That difference had been leaking into every enrollment
link, so an operator handed out fmr-1.echo-lot.app when the thing they
and their users know is fmr.echo-lot.app.

The link now carries the public name and the app asks GET /v1/discover
where to actually connect. The endpoint is plumbing: it exists to select
a certificate, and nobody needs to see it.

Discovery hands out an address and never a pin. The pin stays in the
link. Fetching it over an ordinary TLS connection would make pinning
worth exactly what the certificate authorities are worth, and pinning is
there to survive one the operator does not control — a root injected by
corporate device management, say, which is unremarkable on the networks
this tool gets pointed at. With the pin pre-shared, an intercepted
discovery can only send a device somewhere the pin will not match: an
outage, not a compromise.

Optional on both sides. A server that does not answer, or a link that
already names the control endpoint, works unchanged — enrollment must not
start failing because a lookup did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 00:13:27 +02:00
mrambossekandClaude Opus 5 a720e84411 app: one activity for deep links, and say what re-enrolling actually does
MainActivity had no launchMode, so every echolot:// link stacked a fresh
activity with its own ViewModel. The enrolment then ran in a throwaway
copy and pressing back returned to the original screen showing none of
it — silent, and indistinguishable from the link not working at all.
singleTask plus onNewIntent means the link reaches the screen already in
front of the user.

The confirmation dialog also read as nonsense when re-enrolling with the
server already configured: "already enrolled with X ... enrolling with X
replaces that". Naming one URL twice looks like a bug and buries the
consequence that does apply — the credential is replaced, the old one
stops working at once, and the device appears on the server as a second
entry next to the first, which is worth revoking afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:54:08 +02:00
mrambossekandClaude Opus 5 30d9501507 app: confirm before an enrollment link replaces an existing one
The scheme was already registered and the deep link already worked — it
enrolled on arrival, with no confirmation. Now that the web UI offers the
link as something to follow, that is one tap between a working enrollment
and a replaced one, from a page that might be showing a link minted for a
different device entirely.

Enrolling is not additive: the new credential replaces the old, and on
the previous server this device simply stops reporting. So the link is
held and the user is asked, with both server URLs named — the question is
"which server", and it cannot be answered without seeing both.

The dialog says what survives, because that is the part someone hesitates
over: uploads already on the old server stay there, runs stored on the
phone are untouched, and the device reappears on the new server as a new
device rather than carrying its history across.

Enrolling also clears the cached canary zone. It describes the old
server's deployment, and querying it against the new one would measure
somebody else's zone and file the answer under this network.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:42:37 +02:00
mrambossekandClaude Opus 5 4aaaa5f5d4 app: probe the server this device is enrolled with, not ours
The canary-DNS zone and the STUN host were compiled in as c.echo-lot.app
and fmr-1.echo-lot.app, so every copy of the app measured against this
particular deployment whatever server its owner had enrolled with. On
someone else's install those two tests describe our infrastructure and
report the result as a fact about their network.

The zone comes from the server's own profile, which has advertised
canary_zone all along — the app simply never read it. It is cached in
settings because the canary probe runs at device tier, before anything
has contacted the control plane, and a probe that had to make a call
first would fail on exactly the networks worth measuring. The STUN host
is derived from the configured server URL rather than stored, since a
second copy of the server's name goes stale the moment someone
re-enrolls elsewhere.

With no server configured both now report SKIPPED. StunProbe previously
would have reported FAILED on a blank host, which reads as a finding
about the network when the truth is that no packet was ever sent — the
same conflation between "measured nothing" and "measured a fault" that
the ICMPv6 finding had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:20:52 +02:00
mrambossekandClaude Opus 5 d9ee8bc2ae app: don't report ICMPv6 silence that was never measured
The per-network attribution fix worked — the finding named rmnet_data1
instead of the IPv4-only wifi — and immediately exposed a worse problem
underneath. Cellular's result was `ok: false` because binding a socket to
it failed with EPERM, so no echo request was ever sent; the finding then
reported "IPv6 is configured, but ICMPv6 gets no reply" about a network
the app had never pinged. That is an assertion about the user's carrier
with nothing behind it.

`attempted` now travels beside `ok`, set only once sendto has returned,
and the finding requires both. Failing to bind is a fact about this app's
permissions on this device; it says nothing about the network, and the
two must not share a boolean.

Verified on hardware with a VPN active: every network fails to bind with
EPERM, nothing is sent, and no ICMPv6 finding is emitted — where the
previous build would have blamed the carrier. Recorded in build-status:
Android blocks per-network binding entirely while a VPN holds the default
route, so per-network measurement is unavailable to anyone with one
connected. That needs a deliberate answer rather than a silently green run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:32:34 +02:00
mrambossekandClaude Opus 5 7a5004f293 server: a non-admin account can manage its own uploads
Signing in and being allowed to administer the server were the same
question: the OIDC callback refused a session outright to anyone outside
the admin group. A legitimate user could authenticate, be told what they
could not do, and be left with no way to see or delete the data their own
devices had uploaded.

They are separate questions now. Everyone who authenticates gets a
session; the admin flag rides inside the MAC'd payload, so promoting
yourself means forging a signature rather than editing a cookie, and a
role that does not parse fails closed to "user".

Pages scope themselves through visibleDevices/mayTouchRun rather than
filtering individually — per-page scoping is what the next page added
will be missing, and that failure is silent, since a listing that leaks
other people's uploads looks exactly like one that does not. Someone
else's run answers 404, not 403: a distinguishable refusal would confirm
the run exists. Revoking devices and minting enrolment tokens affect the
whole server and stay behind adminOnly at the route table, where someone
looking for who-may-do-what will actually find it.

Ownership is re-read per request instead of captured at sign-in, so
unlinking an account takes effect immediately rather than at session
expiry. Tests cover that, plus the degenerate case of an empty subject,
which must own nothing rather than everything with an empty account id.

Also: attribute the ICMPv6 finding per network. It compared "is IPv6
configured anywhere on this device" against "did any network answer",
which on a phone reports IPv6-is-broken about a network where IPv6 was
never configured. network_ref is null on every test, so the probe now
records per-network outcomes structurally rather than as prose a finding
would have to parse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:10:38 +02:00
mrambossekandClaude Opus 5 7eaf0c4190 app: name the two half-configured IPv6 shapes, per network
v6.no_icmp_reply infers trouble from silence, which is ambiguous by
construction: a firewall dropping echo requests looks the same as a
network that cannot carry IPv6 at all. Two much stronger signals were
already sitting unread in the link snapshot, and a test device on a
Netbird tunnel surfaced both at once.

v6.route_without_address — a ::/0 route with no global address. The
router advertises itself as an IPv6 gateway while SLAAC produces nothing
usable. Hosts believe IPv6 is available and pay a connection timeout on
every dual-stack destination before falling back, which is felt as
general slowness with no packet loss to explain it.

v6.no_default_route — the mirror: a global address with nothing to route
it. A VPN installing host routes to specific destinations produces this
deliberately and it works, so a VPN transport reports it as INFO rather
than as a fault; without one it means the network handed out an address
it does not carry traffic for.

Both are read from the routing table, so neither is inferred from
silence, and both are reported per interface — "IPv6 is broken" is
useless advice when wifi is the broken one and cellular is fine.

Classification lives in core-measurement rather than the ViewModel so it
can be tested without a device; the fixtures are a real dumpsys table
(wifi advertising a route it cannot source from, working cellular, a
tunnel with two host routes) because the risk here is not bad boolean
logic but imagining shapes real networks do not produce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 20:42:03 +02:00
mrambossekandClaude Fable 5 fec374abf5 findings: v6.broken claimed a cause it had no evidence for
A phone reported "IPv6 is configured but not working" while loading an IPv6-only
site over TCP perfectly well. The finding fired on one signal - ICMPv6 echo
getting no reply - at HIGH confidence. ICMPv6 echo is widely filtered on
networks where IPv6 works, so the two cases are indistinguishable from where the
app stands, and it was picking one.

Same class of error as the multi-homed downstream-loss bug: a confident
measurement of something that was not happening. Now v6.no_icmp_reply, low
severity, medium confidence, naming both explanations. Still reported, because
filtered ICMPv6 breaks Path MTU Discovery - large packets vanish instead of
being reported as too big - which is a fault in its own right.

Corroborating with a real IPv6 connection would separate the two properly, but
needs a target, which runs into the hardcoded-deployment issue already open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 20:16:37 +02:00
mrambossekandClaude Fable 5 b5c8dda9a2 app: sign in to the server's identity provider
Authorization code with PKCE, a Sign in card in settings, and the echolot://auth
redirect handled next to the enrolment one - told apart by host, because one
spends a token and the other completes an authorization, and confusing them
would fail obscurely.

The detail that decides whether this survives a real phone: the PKCE verifier is
written to storage before the browser opens rather than held in memory. Handing
control to a browser backgrounds the process and Android may kill it, so the
callback arrives at a fresh one. An in-memory verifier works on a developer's
device and fails under memory pressure.

Pending state is cleared before the exchange is attempted, whatever the outcome:
it is single-use, and leaving it behind would let a later callback complete a
flow nobody started.

Also records an open issue the question about server requirements surfaced: two
probes hardcode the reference deployment, so a user with no server still sends
DNS and STUN traffic to fmr without being told. For a tool this careful about
what leaves the device, that is the wrong default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 19:53:38 +02:00
mrambossekandClaude Fable 5 57a5ef8796 app: the stable-pseudonym switch was live at a level that pseudonymizes nothing
At `full` the anonymizer returns the document unchanged, so the salt has nothing
to act on - but the switch was enabled and looked like it did something. A
control that silently does nothing is the same class of fault as the preview
button and the archived-level label: the screen implying more than is true.

Shown disabled with the reason rather than hidden. The setting is still stored
and applies the moment the level changes, so making it vanish would hide state
that is still there; and a settings screen whose controls appear and disappear
as you touch other controls is harder to trust, not easier. The label dims with
the switch so "not active right now" reads at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:16:36 +02:00
mrambossekandClaude Fable 5 c19f382640 privacy: scrub identifiers inside raw shell output
Running the Shizuku tier for the first time uploaded every MAC address on the
local network to the server at the balanced level - fourteen of them, router and
all. The probes embed raw command output verbatim (ip neigh, ip route), which is
good evidence and also a complete household device inventory, and the anonymizer
could not see it: classification is by field name and whole-value shape, and
ip_neigh is one long string that is itself neither a MAC nor an address.

measurement-schema.md flagged raw dumps as hard to anonymize and proposed
dropping them from exports. Scrubbing is better: identifiers inside unclassified
strings are replaced in place with the same pseudonyms used elsewhere, so a MAC
appearing in both a parsed field and a raw dump still reads as one device, and
the dump stays readable - neighbour-table shape, host count, RFC1918 addresses
and vendor prefixes all survive. Dropping it would have protected the same data
by destroying the reason for collecting it.

One pass, not three: sequential passes re-process their own output. Once a MAC
became 78:9a:18:xx:yy:zz the IPv6 pattern matched it - six hex groups separated
by colons is an address - and destroyed the vendor prefix the MAC rule had just
preserved. Ordered alternation resolves each position once, MAC first.

RealDocumentTest runs the anonymizer over a captured run when ECHOLOT_REAL_RUN
points at one and fails on any surviving MAC; it self-skips otherwise so no
one's network lands in the repo. Against the document that leaked: 14 in, 0 out.

Also: the Settings preview button did nothing, reading UiState.history which is
empty until the History screen has been opened - same root cause as the "0
run(s)" count. It reads the archive now, and says when there is nothing to show.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 16:12:13 +02:00
mrambossekandClaude Fable 5 8646bab52d findings: adopt the registry in the app module; rename ipv6.* to v6.*
The registry was only used in core-engine. The app still emitted seven codes as
raw strings, so the registry test passed while codes lived outside it - among
them ipv6.broken, which fired on a real network and was in no registry at all.

All seven now take their code, category and severity from a registry entry, so
those three cannot disagree at a call site. Grepping for code = "..." across the
app, engine and probe modules now returns nothing.

ipv6.* -> v6.* is the third instance of the same rule being broken: they
declared Category.IPV6 while the prefix map only knows "v6", so
TestType.category("ipv6.broken") fell through to connectivity and the finding
rolled up under the wrong verdict light. The test-type registry already used v6.

Two severities reconciled rather than assumed:

  connectivity.captive_portal is medium, not high. The registry had guessed
  high; the probe emitting it had always said medium, and the probe was the
  considered value - a captive portal on hotel wifi is what should be there.
  no_internet keeps high, since nothing local fixes that.

  v6.not_offered stays info, and the registry now says why it must. Most
  networks still do not offer IPv6; a warning there lights a yellow verdict on a
  healthy network and teaches people to ignore the light.

Plus a BackHandler: the screen was a plain state variable with nothing tying it
to the back stack, so Back left the app from Settings/History instead of
returning to the run screen.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:47:52 +02:00
mrambossekandClaude Fable 5 6bba420845 app: the history row was naming the wrong document's privacy level
A row read "22 kB · full" directly beneath "uploaded to fmr", while the status
line above said the upload went as BALANCED. Both were true and they described
different documents: the row showed ArchivedRun.anonymization, which describes
the *archived* copy - deliberately unredacted, so always "full" - and the status
line described the *uploaded* copy.

Read together, that says the complete data was uploaded when a redacted copy was
sent. A privacy display that overstates what left the device is worse than none,
and telling the user what left the device is the one thing this screen is for.

The level a run was uploaded at is now recorded separately (uploaded_as) and the
row says "kept complete on this device" / "uploaded to fmr as balanced" - each
label naming the copy it belongs to.

Two more from the same screenshot:

  - Every row showed no verdict. The archive read summary.verdict; the schema
    calls it summary.overall. Silently null on every run, so the list's most
    prominent element was blank while everything else looked fine. The test
    fixture had the same wrong field name, which is why it passed.
  - The status line rendered the server's raw JSON index entry into the UI.

Verified on device: a fresh run archives with verdict "yellow" and
uploaded_as "balanced" beside anonymization "full".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 15:37:05 +02:00
mrambossekandClaude Fable 5 305d21f8a7 app: insets on the two newer screens, and one source for the run count
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>
2026-08-01 15:20:57 +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 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 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 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 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 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 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 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