diff --git a/echolot-app/adb-beacon/.gitignore b/echolot-app/adb-beacon/.gitignore
new file mode 100644
index 0000000..796b96d
--- /dev/null
+++ b/echolot-app/adb-beacon/.gitignore
@@ -0,0 +1 @@
+/build
diff --git a/echolot-app/adb-beacon/build.gradle.kts b/echolot-app/adb-beacon/build.gradle.kts
new file mode 100644
index 0000000..380f4d5
--- /dev/null
+++ b/echolot-app/adb-beacon/build.gradle.kts
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+plugins {
+ // AGP 9 built-in Kotlin (no kotlin.android — see core-probe note).
+ alias(libs.plugins.android.application)
+}
+
+// Dev tool (NOT the product app): reports this phone's rotating wireless-debug
+// endpoint to the fmr beacon so the PC can keep `adb connect` current. Reads
+// the connect port from adbd's own mDNS advertisement (_adb-tls-connect._tcp)
+// via NsdManager — no root, no Shizuku.
+android {
+ namespace = "app.echo_lot.adbbeacon"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "app.echo_lot.adbbeacon"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 1
+ versionName = "0.1.0"
+ // Prefilled beacon config (dev tool — secret in the APK is fine).
+ buildConfigField("String", "BEACON_URL", "\"http://fmr-1.echo-lot.app:9099/beacon\"")
+ buildConfigField("String", "BEACON_SECRET", "\"D4OmG5gGJsElqVVbtYIZbR\"")
+ }
+ buildTypes { release { isMinifyEnabled = false } }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ buildFeatures { buildConfig = true }
+}
+
+dependencies {
+ implementation(libs.kotlinx.coroutines.android)
+ implementation(libs.androidx.core.ktx)
+ implementation("androidx.activity:activity:1.9.3")
+}
diff --git a/echolot-app/adb-beacon/src/main/AndroidManifest.xml b/echolot-app/adb-beacon/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a246722
--- /dev/null
+++ b/echolot-app/adb-beacon/src/main/AndroidManifest.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/BeaconService.kt b/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/BeaconService.kt
new file mode 100644
index 0000000..f5a4029
--- /dev/null
+++ b/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/BeaconService.kt
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.adbbeacon
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import android.os.IBinder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import java.net.HttpURLConnection
+import java.net.Inet4Address
+import java.net.NetworkInterface
+import java.net.URL
+
+/**
+ * Foreground service that tracks this phone's wireless-debug endpoint and reports it to the fmr
+ * beacon. The port comes from adbd's own mDNS advertisement (`_adb-tls-connect._tcp`) via
+ * NsdManager — continuous discovery, so a port rotation re-fires and re-reports within seconds.
+ * The IP is the wlan0 private IPv4 (what the PC routes to). No root, no Shizuku.
+ */
+class BeaconService : Service() {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private lateinit var nsd: NsdManager
+ private var discoveryListener: NsdManager.DiscoveryListener? = null
+
+ @Volatile private var currentPort: Int = -1
+ private var heartbeat: Job? = null
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ nsd = getSystemService(Context.NSD_SERVICE) as NsdManager
+ startForeground(1, buildNotification("Starting…"))
+ startDiscovery()
+ heartbeat = scope.launch {
+ while (true) {
+ report() // refresh seen_at + re-assert current endpoint
+ delay(20_000)
+ }
+ }
+ Status.set("watching for wireless-debug port…")
+ }
+
+ private fun startDiscovery() {
+ val listener = object : NsdManager.DiscoveryListener {
+ override fun onStartDiscoveryFailed(t: String?, code: Int) { Status.set("NSD start failed ($code)") }
+ override fun onStopDiscoveryFailed(t: String?, code: Int) {}
+ override fun onDiscoveryStarted(t: String?) {}
+ override fun onDiscoveryStopped(t: String?) {}
+ override fun onServiceLost(s: NsdServiceInfo?) { currentPort = -1 }
+ override fun onServiceFound(s: NsdServiceInfo?) {
+ if (s == null) return
+ resolve(s)
+ }
+ }
+ discoveryListener = listener
+ runCatching {
+ nsd.discoverServices("_adb-tls-connect._tcp", NsdManager.PROTOCOL_DNS_SD, listener)
+ }.onFailure { Status.set("NSD unavailable: ${it.message}") }
+ }
+
+ @Suppress("DEPRECATION")
+ private fun resolve(info: NsdServiceInfo) {
+ nsd.resolveService(info, object : NsdManager.ResolveListener {
+ override fun onResolveFailed(s: NsdServiceInfo?, code: Int) {}
+ override fun onServiceResolved(s: NsdServiceInfo?) {
+ val port = s?.port ?: return
+ currentPort = port
+ scope.launch { report() }
+ }
+ })
+ }
+
+ private fun report() {
+ val ip = wifiIpv4() ?: run { Status.set("no wlan0 IPv4 (is wifi up?)"); return }
+ val port = currentPort
+ if (port <= 0) { Status.set("$ip — waiting for wireless-debug port"); return }
+ val body = """{"ip":"$ip","port":$port}"""
+ val ok = runCatching {
+ (URL(BuildConfig.BEACON_URL).openConnection() as HttpURLConnection).run {
+ requestMethod = "POST"
+ connectTimeout = 5000; readTimeout = 5000
+ doOutput = true
+ setRequestProperty("Content-Type", "application/json")
+ setRequestProperty("X-Beacon-Secret", BuildConfig.BEACON_SECRET)
+ outputStream.use { it.write(body.toByteArray()) }
+ responseCode == 200
+ }
+ }.getOrDefault(false)
+ val line = if (ok) "reported $ip:$port ✓" else "report failed for $ip:$port (beacon unreachable?)"
+ Status.set(line)
+ updateNotification(line)
+ }
+
+ /** The wlan0 (or first private) IPv4 the PC routes to. */
+ private fun wifiIpv4(): String? {
+ return runCatching {
+ NetworkInterface.getNetworkInterfaces().asSequence()
+ .filter { it.isUp && !it.isLoopback }
+ .sortedByDescending { it.name.startsWith("wlan") } // prefer wlan0
+ .flatMap { it.inetAddresses.asSequence() }
+ .filterIsInstance()
+ .firstOrNull { it.isSiteLocalAddress }
+ ?.hostAddress
+ }.getOrNull()
+ }
+
+ private fun buildNotification(text: String): Notification {
+ val channelId = "beacon"
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val nm = getSystemService(NotificationManager::class.java)
+ nm.createNotificationChannel(
+ NotificationChannel(channelId, "ADB Beacon", NotificationManager.IMPORTANCE_LOW)
+ )
+ }
+ return Notification.Builder(this, channelId)
+ .setContentTitle("Echolot ADB Beacon")
+ .setContentText(text)
+ .setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
+ .setOngoing(true)
+ .build()
+ }
+
+ private fun updateNotification(text: String) {
+ getSystemService(NotificationManager::class.java).notify(1, buildNotification(text))
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
+
+ override fun onDestroy() {
+ discoveryListener?.let { runCatching { nsd.stopServiceDiscovery(it) } }
+ heartbeat?.cancel()
+ scope.cancel()
+ Status.set("stopped")
+ super.onDestroy()
+ }
+}
+
+/** Tiny shared status the activity polls (keeps the app dependency-free of observers). */
+object Status {
+ @Volatile var line: String = "idle"; private set
+ fun set(s: String) { line = s }
+}
diff --git a/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/MainActivity.kt b/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/MainActivity.kt
new file mode 100644
index 0000000..5cce8d8
--- /dev/null
+++ b/echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/MainActivity.kt
@@ -0,0 +1,74 @@
+// SPDX-FileCopyrightText: 2026 Echolot contributors
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package app.echo_lot.adbbeacon
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.Gravity
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.core.content.ContextCompat
+
+/**
+ * Minimal control surface for the beacon: Start/Stop the foreground service and show its live
+ * status. Deliberately plain (no Compose) — it's a dev tool.
+ */
+class MainActivity : ComponentActivity() {
+
+ private lateinit var status: TextView
+ private val ui = Handler(Looper.getMainLooper())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ if (Build.VERSION.SDK_INT >= 33 &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1)
+ }
+
+ val root = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ setPadding(48, 64, 48, 48)
+ }
+ val title = TextView(this).apply { text = "Echolot ADB Beacon"; textSize = 22f }
+ val subtitle = TextView(this).apply {
+ text = "Reports this phone's wireless-debug endpoint to fmr so the PC can keep adb connected.\n\n" +
+ "Enable Wireless debugging, then Start."
+ textSize = 13f; setPadding(0, 16, 0, 32)
+ }
+ status = TextView(this).apply { text = Status.line; textSize = 14f; gravity = Gravity.START }
+
+ val start = Button(this).apply {
+ text = "Start beacon"
+ setOnClickListener {
+ val i = Intent(this@MainActivity, BeaconService::class.java)
+ ContextCompat.startForegroundService(this@MainActivity, i)
+ }
+ }
+ val stop = Button(this).apply {
+ text = "Stop beacon"
+ setOnClickListener { stopService(Intent(this@MainActivity, BeaconService::class.java)) }
+ }
+
+ root.addView(title); root.addView(subtitle)
+ root.addView(start); root.addView(stop)
+ root.addView(TextView(this).apply { text = "\nStatus:"; setPadding(0, 32, 0, 8) })
+ root.addView(status)
+ setContentView(root)
+
+ poll()
+ }
+
+ private fun poll() {
+ status.text = Status.line
+ ui.postDelayed({ poll() }, 1000)
+ }
+}
diff --git a/echolot-app/settings.gradle.kts b/echolot-app/settings.gradle.kts
index f041a49..f60bc10 100644
--- a/echolot-app/settings.gradle.kts
+++ b/echolot-app/settings.gradle.kts
@@ -26,3 +26,4 @@ include(":core-measurement")
include(":core-engine")
include(":core-probe")
include(":app")
+include(":adb-beacon")
diff --git a/tools/adb-beacon/receiver.py b/tools/adb-beacon/receiver.py
new file mode 100644
index 0000000..44ca28c
--- /dev/null
+++ b/tools/adb-beacon/receiver.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2026 Echolot contributors
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+# Tiny rendezvous receiver for the wireless-adb beacon. The phone POSTs its
+# current wireless-debug endpoint here; the PC-side connector reads the stored
+# file (over SSH) and runs `adb connect`. Dev tooling — not part of the product.
+#
+# POST /beacon { "ip": "...", "port": N } header: X-Beacon-Secret:
+# GET /beacon -> the last stored JSON (no secret; it's just an ip:port)
+#
+# Secret comes from ECHOLOT_BEACON_SECRET; stored state goes to STATE_PATH.
+# Bind is 0.0.0.0:9099 so the phone can reach it over the internet/VPN.
+
+import json
+import os
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+STATE_PATH = os.environ.get("STATE_PATH", "/run/echolot-adb-beacon.json")
+SECRET = os.environ.get("ECHOLOT_BEACON_SECRET", "")
+BIND = os.environ.get("BEACON_BIND", "0.0.0.0")
+PORT = int(os.environ.get("BEACON_PORT", "9099"))
+
+
+class Handler(BaseHTTPRequestHandler):
+ def _send(self, code, body=b"", ctype="application/json"):
+ self.send_response(code)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ if body:
+ self.wfile.write(body)
+
+ def do_GET(self):
+ if self.path.rstrip("/") != "/beacon":
+ return self._send(404, b'{"error":"not found"}')
+ try:
+ with open(STATE_PATH, "rb") as f:
+ self._send(200, f.read())
+ except FileNotFoundError:
+ self._send(200, b'{"available":false}')
+
+ def do_POST(self):
+ if self.path.rstrip("/") != "/beacon":
+ return self._send(404, b'{"error":"not found"}')
+ if SECRET and self.headers.get("X-Beacon-Secret") != SECRET:
+ return self._send(403, b'{"error":"bad secret"}')
+ n = int(self.headers.get("Content-Length", "0"))
+ try:
+ data = json.loads(self.rfile.read(n) or b"{}")
+ ip = str(data["ip"])
+ port = int(data["port"])
+ except Exception:
+ return self._send(400, b'{"error":"need {ip, port}"}')
+ record = {"ip": ip, "port": port, "seen_at": int(time.time())}
+ tmp = STATE_PATH + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(record, f)
+ os.replace(tmp, STATE_PATH)
+ self._send(200, json.dumps(record).encode())
+
+ def log_message(self, *_):
+ pass # quiet
+
+
+if __name__ == "__main__":
+ print(f"adb-beacon receiver on {BIND}:{PORT} -> {STATE_PATH}", flush=True)
+ ThreadingHTTPServer((BIND, PORT), Handler).serve_forever()