Files
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

12 KiB

Echolot — context for Claude Code

This repo is the capability prober for the Echolot project: an F/OSS Android app for detecting and debugging local network issues (target audience: network engineers). The prober validates the no-root feasibility matrix on real hardware before the production app is built.

Project facts

  • Name: Echolot. Domain echo-lot.app. URI scheme echolot://. Namespace app.echo_lot.* (hyphen→underscore; appIds/packages can't contain hyphens). Prober appId: app.echo_lot.prober.
  • Stack: native Kotlin + Jetpack Compose. No Flutter (all value is in platform APIs; Flutter would be a Dart skin over a full Kotlin app + F-Droid build friction).
  • Tiers: app (no root), shizuku (ADB-shell privileges via wireless pairing, shipped in v1), root (future module). Every result records which tier produced it.
  • License: code is GPL-3.0-or-later (LICENSE), the specs in docs/ are CC-BY-4.0 (docs/LICENSE). New source files get the two-line SPDX header the existing ones carry. The server, when it lands, is GPL too — not AGPL (see docs/build-status.md for the reasoning).

Design docs (source of truth, in docs/)

  • docs/feature-catalog-and-feasibility.md — full feature list + no-root feasibility matrix.
  • docs/measurement-schema.md — the archived/exportable measurement JSON format (observation vs finding separation, two-clock rule, columnar trains, anonymization types).
  • docs/probe-protocol.md — client↔server wire protocol (pinned-TLS control plane, binary UDP data plane with anti-amplification HMAC, STUN, canary DNS reference records).
  • docs/build-status.md — running log of decisions, what is delivered, and the next steps.

The three specs are draft-complete and user-reviewed; treat them as the contract. build-status.md is the mutable one — update it as work lands.

Keep prober result IDs aligned with the measurement-schema test-type registry.

Versioning

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_versioncan 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

Monorepo. The prober is one deliverable; the Go server and the production app land as siblings.

docs/                          design docs (above) — apply repo-wide, not just to the prober
echolot-prober/                the capability prober (self-contained Gradle build)
  app/src/main/java/app/echo_lot/prober/
    MainActivity.kt            Compose host, runs probes sequentially
    probe/                     Probe interface + all app-tier probes + OS ABI helper
    shizuku/                   AIDL UserService + ShizukuRunner (shell command exec)
    export/Report.kt           JSON report + share intent
    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).
  • Uncertain OS API paths (getsockoptInt, recvmsg, nat64Prefix) are reached via reflection/runCatching and reported, not assumed — the prober's purpose is to discover what exists.
  • Hardcoded Linux sockopt ABI numbers live in OsAbi.kt with rationale; do not "fix" them to OsConstants names that don't exist.

Build

From echolot-prober/: ./gradlew :app:assembleDebug (needs local Android SDK; set sdk.dir in echolot-prober/local.properties). The Gradle build is rooted there, not at the repo root. First build downloads AGP/Compose/Shizuku from Google Maven + Maven Central.

Working with test devices (hard-won lessons)

  • Prefer USB for adb. Wireless debugging dies constantly: probe runs churn the wifi the debug link rides on, the Lenovo tablet's ZUI power management kills the listener anyway, and the port rotates on every restart. Never drive a probe run over wireless adb.
  • Collection loop over USB, fully driveable by Claude: install → am start → tap "Run all probes" → poll uiautomator dump until the button label returns to "Run all probes" → tap "Export JSON" → the report lands in cache/reports/ and comes back via adb shell run-as app.echo_lot.prober cat … (no need to drive the share sheet; dismiss it with BACK). Archive under echolot-prober/reports/, record findings in docs/build-status.md.
  • Bash-tool adb shell calls with absolute device paths get mangled by Git Bash path conversion (/data/…C:/Program Files/Git/data/…); use the PowerShell tool for those.
  • Bump versionCode on every deployed prober change — it shows on screen and as proberBuild in the report; that's how a report is matched to a build.
  • 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).
  • A poisoned Gradle build cache entry can silently drop a whole module from the APK. Symptom: the app dies with ClassNotFoundException for a class that plainly exists, while the build is green and ./gradlew :app:dependencies lists the module on debugRuntimeClasspath. The module's own jar is correct; its code simply never reaches AGP's intermediates. clean, rm -rf */build and --rerun-tasks all fail to fix it, because none of them touch the build cache — look for compileKotlin FROM-CACHE in the log. Fix: rebuild with --no-build-cache. Verify by grepping the APK's dex for a string literal that only that module defines; grepping for a class name proves nothing, because callers carry the name as a reference whether or not the class is packaged: unzip -o -q app-debug.apk "classes*.dex" && grep -a "pin-sha256:" *.dex Suspect this whenever a runtime failure contradicts a successful build.
  • 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.

Likely next steps

  1. Run on physical devices; collect JSON reports across Android versions/vendors.
  2. C-over-JNI errqueue shim — not needed; trace.errqueue_reachable and traceroute.udp4 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.