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-probe")
|
||||
include(":app")
|
||||
include(":adb-beacon")
|
||||
|
||||
Reference in New Issue
Block a user