From 52748ac853a476e638e9c9257f1ebaff4b7853f1 Mon Sep 17 00:00:00 2001 From: mrambossek Date: Fri, 31 Jul 2026 21:50:33 +0200 Subject: [PATCH] =?UTF-8?q?tools:=20wireless-adb=20beacon=20=E2=80=94=20se?= =?UTF-8?q?lf-healing=20bridge=20across=20drops=20+=20port=20rotation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- echolot-app/adb-beacon/.gitignore | 1 + echolot-app/adb-beacon/build.gradle.kts | 39 +++++ .../adb-beacon/src/main/AndroidManifest.xml | 34 ++++ .../app/echo_lot/adbbeacon/BeaconService.kt | 158 ++++++++++++++++++ .../app/echo_lot/adbbeacon/MainActivity.kt | 74 ++++++++ echolot-app/settings.gradle.kts | 1 + tools/adb-beacon/receiver.py | 69 ++++++++ 7 files changed, 376 insertions(+) create mode 100644 echolot-app/adb-beacon/.gitignore create mode 100644 echolot-app/adb-beacon/build.gradle.kts create mode 100644 echolot-app/adb-beacon/src/main/AndroidManifest.xml create mode 100644 echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/BeaconService.kt create mode 100644 echolot-app/adb-beacon/src/main/kotlin/app/echo_lot/adbbeacon/MainActivity.kt create mode 100644 tools/adb-beacon/receiver.py 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()