tools: wireless-adb beacon — self-healing bridge across drops + port rotation
The phone's wireless-debug port rotates and the bridge drops; this makes
adb reconnect automatically. Three pieces:
- adb-beacon (Android dev app): reads adbd's own mDNS advertisement
(_adb-tls-connect._tcp) via NsdManager for the live connect port — no
root, no Shizuku — plus the wlan0 IPv4, and POSTs {ip,port} to fmr every
time it changes (continuous NSD discovery catches rotation in seconds).
Foreground service (specialUse) so it survives backgrounding.
- tools/adb-beacon/receiver.py: ~30-line rendezvous on fmr:9099 (secret-
gated POST stores the latest endpoint; GET returns it). Deployed as
echolot-adb-beacon.service.
- PC connector polls the endpoint and keeps `adb connect` current.
Dev tooling, separate from the product. Bootstrap: sideload the beacon
APK once (no adb needed); thereafter adb self-heals for everything.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bc220f950e
commit
52748ac853
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Echolot contributors
|
||||||
|
SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:label="Echolot ADB Beacon"
|
||||||
|
android:theme="@android:style/Theme.Material.Light">
|
||||||
|
|
||||||
|
<activity android:name=".MainActivity" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".BeaconService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="wireless-debug-endpoint-beacon" />
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -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<Inet4Address>()
|
||||||
|
.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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,3 +26,4 @@ include(":core-measurement")
|
|||||||
include(":core-engine")
|
include(":core-engine")
|
||||||
include(":core-probe")
|
include(":core-probe")
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":adb-beacon")
|
||||||
|
|||||||
@@ -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: <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()
|
||||||
Reference in New Issue
Block a user