app: make the relay come back by itself, and report within seconds

Two faults found by watching it run. The heartbeat was the only cadence,
so the first pass ran before mDNS discovery finished, saw nothing, and
then sat silent for two minutes - precisely when someone has just
switched the relay on and is watching for it to work. It now polls every
five seconds until the first successful report, then settles.

And nothing restarted the service after a reboot or an app update, which
is the most common way it dies during development. A relay that has
quietly stopped is worse than one never switched on: the endpoint it last
published keeps looking authoritative while pointing at a port nothing is
listening on. RelayBootReceiver handles BOOT_COMPLETED and
MY_PACKAGE_REPLACED, debug builds only, and does nothing unless the relay
was already on.

Verified end to end: installing this build triggered the receiver and the
phone reported its endpoint 7 seconds later, unattended.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrambossek
2026-08-02 15:27:32 +02:00
co-authored by Claude Opus 5
parent 97515f7090
commit 7a88a2d4b9
3 changed files with 58 additions and 1 deletions
@@ -21,6 +21,8 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Only so the relay's ongoing status is visible; the service runs either way. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- So the relay survives a reboot of a device left running it (see RelayBootReceiver). -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:allowBackup="false"
@@ -73,6 +75,19 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!--
Exported because the system delivers these broadcasts; the receiver itself starts
nothing unless the relay was already switched on in a debug build.
-->
<receiver
android:name=".RelayBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -56,6 +56,11 @@ class AdbRelayService : Service() {
r.start()
scope.launch {
// Poll quickly until the first endpoint has actually been reported, then settle into
// the heartbeat. Discovery takes a few seconds, so a loop that only ever waited the
// heartbeat would see nothing on its first pass and then sit silent for two minutes —
// exactly when someone has just switched the relay on and is watching for it to work.
var reportedOnce = false
while (true) {
val ep = r.lastEndpoint
if (ep != null) {
@@ -66,13 +71,14 @@ class AdbRelayService : Service() {
val result = post(settings, ep)
status = if (result == null) {
lastPosted = wire
reportedOnce = true
"reported $wire"
} else {
"found $wire, but reporting failed: $result"
}
notify(status)
}
delay(HEARTBEAT_MS)
delay(if (reportedOnce) HEARTBEAT_MS else STARTUP_POLL_MS)
}
}
}
@@ -143,6 +149,9 @@ class AdbRelayService : Service() {
*/
private const val HEARTBEAT_MS = 120_000L
/** Retry cadence before the first successful report; cheap, and only ever runs at start. */
private const val STARTUP_POLL_MS = 5_000L
fun start(ctx: Context) {
val i = Intent(ctx, AdbRelayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
@@ -0,0 +1,33 @@
// SPDX-FileCopyrightText: 2026 Echolot contributors
// SPDX-License-Identifier: GPL-3.0-or-later
package app.echo_lot.app
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Brings the relay back after a reboot or an app update, without anyone opening the app.
*
* The relay's whole purpose is to keep answering "where is this device" while the device sits on a
* shelf unattended. Starting it only from [MainActivity] meant it silently did not come back from
* either event — and a relay that has quietly stopped is worse than one that was never switched
* on, because the endpoint it last published keeps looking authoritative while pointing at a port
* nothing is listening on.
*
* `MY_PACKAGE_REPLACED` matters as much as boot here: installing a new build is the single most
* common way this service dies during development, which is exactly when it is being relied on.
*/
class RelayBootReceiver : BroadcastReceiver() {
override fun onReceive(ctx: Context, intent: Intent) {
if (!BuildConfig.DEBUG) return
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
if (Settings(ctx).adbRelayEnabled) {
runCatching { AdbRelayService.start(ctx) }
}
}
}
}
}