feat(android): add authenticated presence alive beacons (#73373)

* feat: add Android presence alive beacons

* fix: harden Android presence beacon review findings

* fix: address Android presence review findings
This commit is contained in:
Peter Steinberger
2026-04-28 08:55:06 +01:00
committed by GitHub
parent c788aa025e
commit 04e774eeac
8 changed files with 403 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
### Changes
- iOS/Gateway: add an authenticated `node.presence.alive` protocol event and `node.list` last-seen fields so background iOS wakes can mark paired nodes recently alive without treating them as connected. Carries forward #63123. Thanks @ngutman.
- Android: publish authenticated `node.presence.alive` events after node connect and background transitions so paired Android nodes retain durable last-seen metadata after disconnects. Carries forward #63123. Thanks @ngutman.
- Gateway/chat: accept non-image attachments through `chat.send` by staging them as agent-readable media paths, while keeping unsupported RPC attachment paths explicit instead of silently dropping files. Fixes #48123. (#67572) Thanks @samzong.
- Security/networking: add opt-in operator-managed outbound proxy routing (proxy.enabled + proxy.proxyUrl/OPENCLAW_PROXY_URL) with strict http:// forward-proxy validation, loopback-only Gateway bypass, and cleanup of proxy env/dispatcher state on exit. (#70044) Thanks @jesse-merhi and @joshavant.

View File

@@ -15,6 +15,7 @@ Status: **extremely alpha**. The app is actively being rebuilt from the ground u
- [x] Request camera/location and other permissions in onboarding/settings flow
- [x] Push notifications for gateway/chat status updates
- [x] Security hardening (biometric lock, token handling, safer defaults)
- [x] Authenticated background presence beacons
- [x] Voice tab full functionality
- [x] Screen tab full functionality
- [ ] Full end-to-end QA and release hardening

View File

@@ -247,6 +247,7 @@ class NodeRuntime(
private var gatewayAgents: List<GatewayAgentSummary> = emptyList()
private var didAutoRequestCanvasRehydrate = false
private val canvasRehydrateSeq = AtomicLong(0)
@Volatile private var nodePresenceAliveLastSuccessAtMs: Long? = null
private var operatorConnected = false
private var operatorStatusText: String = "Offline"
private var nodeStatusText: String = "Offline"
@@ -302,6 +303,7 @@ class NodeRuntime(
_canvasRehydrateErrorText.value = null
updateStatus()
showLocalCanvasOnConnect()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
val endpoint = connectedEndpoint
val auth = activeGatewayAuth
if (endpoint != null && auth != null) {
@@ -649,6 +651,60 @@ class NodeRuntime(
reconnectPreferredGatewayOnForeground()
} else {
stopManualVoiceSession()
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Background, throttleRecentSuccess = true)
}
}
private fun publishNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean = false,
) {
scope.launch {
sendNodePresenceAliveBeacon(trigger = trigger, throttleRecentSuccess = throttleRecentSuccess)
}
}
private suspend fun sendNodePresenceAliveBeacon(
trigger: NodePresenceAliveBeacon.Trigger,
throttleRecentSuccess: Boolean,
) {
if (!_nodeConnected.value) return
val nowMs = System.currentTimeMillis()
if (
throttleRecentSuccess &&
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = nowMs,
lastSuccessAtMs = nodePresenceAliveLastSuccessAtMs,
)
) {
return
}
val client = connectionManager.buildClientInfo(clientId = "openclaw-android", clientMode = "node")
val payloadJson =
NodePresenceAliveBeacon.makePayloadJson(
trigger = trigger,
sentAtMs = nowMs,
displayName = client.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: "Android",
version = client.version,
platform = NodePresenceAliveBeacon.androidPlatformLabel(),
deviceFamily = client.deviceFamily,
modelIdentifier = client.modelIdentifier,
)
val result =
nodeSession.sendNodeEventDetailed(
event = NodePresenceAliveBeacon.EVENT_NAME,
payloadJson = payloadJson,
)
if (!result.ok) return
val response = NodePresenceAliveBeacon.decodeResponse(result.payloadJson)
if (response?.handled == true) {
nodePresenceAliveLastSuccessAtMs = nowMs
} else {
Log.d(
"OpenClawNode",
"node.presence.alive not handled: ${NodePresenceAliveBeacon.sanitizeReasonForLog(response?.reason)}",
)
}
}

View File

@@ -184,20 +184,47 @@ class GatewaySession(
suspend fun sendNodeEvent(event: String, payloadJson: String?): Boolean {
val conn = currentConnection ?: return false
val params =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
try {
conn.request("node.event", params, timeoutMs = 8_000)
return true
return try {
conn.request(
"node.event",
buildNodeEventParams(event = event, payloadJson = payloadJson),
timeoutMs = 8_000,
)
true
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err.message ?: err::class.java.simpleName}")
return false
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
false
}
}
suspend fun sendNodeEventDetailed(event: String, payloadJson: String?, timeoutMs: Long = 8_000): RpcResult {
val conn =
currentConnection
?: return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "not connected"),
)
val params = buildNodeEventParams(event = event, payloadJson = payloadJson)
try {
val res = conn.request("node.event", params, timeoutMs = timeoutMs)
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
} catch (err: Throwable) {
Log.w("OpenClawGateway", "node.event failed: ${err::class.java.simpleName}")
return RpcResult(
ok = false,
payloadJson = null,
error = ErrorShape("UNAVAILABLE", "node.event failed"),
)
}
}
private fun buildNodeEventParams(event: String, payloadJson: String?): JsonObject =
buildJsonObject {
put("event", JsonPrimitive(event))
put("payloadJSON", JsonPrimitive(payloadJson ?: "{}"))
}
suspend fun request(method: String, paramsJson: String?, timeoutMs: Long = 15_000): String {
val res = requestDetailed(method = method, paramsJson = paramsJson, timeoutMs = timeoutMs)
if (res.ok) return res.payloadJson ?: ""

View File

@@ -0,0 +1,92 @@
package ai.openclaw.app.node
import android.os.Build
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
internal object NodePresenceAliveBeacon {
const val EVENT_NAME: String = "node.presence.alive"
const val MIN_SUCCESS_INTERVAL_MS: Long = 10 * 60 * 1000
private const val MAX_RESPONSE_JSON_CHARS: Int = 16 * 1024
enum class Trigger(val rawValue: String) {
Background("background"),
SilentPush("silent_push"),
BackgroundAppRefresh("bg_app_refresh"),
SignificantLocation("significant_location"),
Manual("manual"),
Connect("connect"),
}
data class ResponsePayload(
val ok: Boolean?,
val event: String?,
val handled: Boolean?,
val reason: String?,
)
private val json = Json { ignoreUnknownKeys = true }
fun shouldSkipRecentSuccess(
nowMs: Long,
lastSuccessAtMs: Long?,
minIntervalMs: Long = MIN_SUCCESS_INTERVAL_MS,
): Boolean {
val last = lastSuccessAtMs ?: return false
if (last <= 0) return false
val elapsed = nowMs - last
return elapsed >= 0 && elapsed < minIntervalMs
}
fun androidPlatformLabel(): String {
val release = Build.VERSION.RELEASE?.trim().orEmpty().ifEmpty { "unknown" }
return "Android $release (SDK ${Build.VERSION.SDK_INT})"
}
fun makePayloadJson(
trigger: Trigger,
sentAtMs: Long,
displayName: String,
version: String,
platform: String,
deviceFamily: String?,
modelIdentifier: String?,
pushTransport: String? = null,
): String =
buildJsonObject {
put("trigger", JsonPrimitive(trigger.rawValue))
put("sentAtMs", JsonPrimitive(sentAtMs))
put("displayName", JsonPrimitive(displayName))
put("version", JsonPrimitive(version))
put("platform", JsonPrimitive(platform))
deviceFamily?.trim()?.takeIf { it.isNotEmpty() }?.let { put("deviceFamily", JsonPrimitive(it)) }
modelIdentifier?.trim()?.takeIf { it.isNotEmpty() }?.let { put("modelIdentifier", JsonPrimitive(it)) }
pushTransport?.trim()?.takeIf { it.isNotEmpty() }?.let { put("pushTransport", JsonPrimitive(it)) }
}.toString()
fun decodeResponse(payloadJson: String?): ResponsePayload? {
val raw = payloadJson?.trim()?.takeIf { it.isNotEmpty() } ?: return null
if (raw.length > MAX_RESPONSE_JSON_CHARS) return null
val obj =
try {
json.parseToJsonElement(raw).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
return ResponsePayload(
ok = parseJsonBooleanFlag(obj, "ok"),
event = parseJsonString(obj, "event"),
handled = parseJsonBooleanFlag(obj, "handled"),
reason = parseJsonString(obj, "reason"),
)
}
fun sanitizeReasonForLog(raw: String?): String {
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: "unsupported"
return value
.map { ch -> if (ch.isISOControl()) ' ' else ch }
.joinToString("")
.take(200)
}
}

View File

@@ -474,6 +474,111 @@ class GatewaySessionInvokeTest {
}
}
@Test
fun sendNodeEventDetailed_sendsPresenceAlivePayloadAndReturnsStructuredResponse() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val nodeEventParams = CompletableDeferred<JsonObject>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
}
"node.event" -> {
if (!nodeEventParams.isCompleted) {
nodeEventParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap()))
}
val payload =
"""{"ok":true,"event":"node.presence.alive","handled":true,"reason":"persisted"}"""
webSocket.send(
"""{"type":"res","id":"$id","ok":true,"payload":$payload}""",
)
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val result =
harness.session.sendNodeEventDetailed(
event = "node.presence.alive",
payloadJson = """{"trigger":"connect","sentAtMs":123}""",
timeoutMs = TEST_TIMEOUT_MS,
)
val params = withTimeout(TEST_TIMEOUT_MS) { nodeEventParams.await() }
val response = json.parseToJsonElement(result.payloadJson.orEmpty()).jsonObject
val payload = json.parseToJsonElement(params["payloadJSON"]?.jsonPrimitive?.content.orEmpty()).jsonObject
assertEquals(true, result.ok)
assertEquals("node.presence.alive", params["event"]?.jsonPrimitive?.content)
assertEquals("connect", payload["trigger"]?.jsonPrimitive?.content)
assertEquals("123", payload["sentAtMs"]?.jsonPrimitive?.content)
assertEquals(true, response["handled"]?.jsonPrimitive?.content?.toBooleanStrict())
assertEquals("persisted", response["reason"]?.jsonPrimitive?.content)
} finally {
shutdownHarness(harness, server)
}
}
@Test
fun sendNodeEvent_preservesCompletedRpcAsSuccessWhenGatewayReturnsError() = runBlocking {
val json = testJson()
val connected = CompletableDeferred<Unit>()
val nodeEventParams = CompletableDeferred<JsonObject>()
val lastDisconnect = AtomicReference("")
val server =
startGatewayServer(json) { webSocket, id, method, frame ->
when (method) {
"connect" -> {
webSocket.send(connectResponseFrame(id))
}
"node.event" -> {
if (!nodeEventParams.isCompleted) {
nodeEventParams.complete(frame["params"]?.jsonObject ?: JsonObject(emptyMap()))
}
webSocket.send(
"""{"type":"res","id":"$id","ok":false,"error":{"code":"RATE_LIMITED","message":"slow down"}}""",
)
webSocket.close(1000, "done")
}
}
}
val harness =
createNodeHarness(
connected = connected,
lastDisconnect = lastDisconnect,
) { GatewaySession.InvokeResult.ok("""{"handled":true}""") }
try {
connectNodeSession(harness.session, server.port)
awaitConnectedOrThrow(connected, lastDisconnect, server)
val sent =
harness.session.sendNodeEvent(
event = "agent.request",
payloadJson = """{"message":"restore"}""",
)
val params = withTimeout(TEST_TIMEOUT_MS) { nodeEventParams.await() }
assertEquals(true, sent)
assertEquals("agent.request", params["event"]?.jsonPrimitive?.content)
} finally {
shutdownHarness(harness, server)
}
}
private fun testJson(): Json = Json { ignoreUnknownKeys = true }
private fun createNodeHarness(

View File

@@ -0,0 +1,100 @@
package ai.openclaw.app.node
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NodePresenceAliveBeaconTest {
@Test
fun shouldSkipRecentSuccess_requiresFreshSuccess() {
assertTrue(
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = 2_000,
lastSuccessAtMs = 1_500,
minIntervalMs = 1_000,
),
)
assertFalse(
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = 2_000,
lastSuccessAtMs = null,
minIntervalMs = 1_000,
),
)
assertFalse(
NodePresenceAliveBeacon.shouldSkipRecentSuccess(
nowMs = 3_000,
lastSuccessAtMs = 1_500,
minIntervalMs = 1_000,
),
)
}
@Test
fun makePayloadJson_includesAndroidPresenceMetadata() {
val payload =
Json.parseToJsonElement(
NodePresenceAliveBeacon.makePayloadJson(
trigger = NodePresenceAliveBeacon.Trigger.Connect,
sentAtMs = 123,
displayName = "Pixel Node",
version = "2026.4.28",
platform = "Android 15 (SDK 35)",
deviceFamily = "Android",
modelIdentifier = "Google Pixel 9",
),
).jsonObject
assertEquals("connect", payload["trigger"]?.jsonPrimitive?.content)
assertEquals("123", payload["sentAtMs"]?.jsonPrimitive?.content)
assertEquals("Pixel Node", payload["displayName"]?.jsonPrimitive?.content)
assertEquals("2026.4.28", payload["version"]?.jsonPrimitive?.content)
assertEquals("Android 15 (SDK 35)", payload["platform"]?.jsonPrimitive?.content)
assertEquals("Android", payload["deviceFamily"]?.jsonPrimitive?.content)
assertEquals("Google Pixel 9", payload["modelIdentifier"]?.jsonPrimitive?.content)
assertNull(payload["pushTransport"])
}
@Test
fun decodeResponse_leavesOldGatewayAckUnhandled() {
val response = NodePresenceAliveBeacon.decodeResponse("""{"ok":true}""")
assertEquals(true, response?.ok)
assertNull(response?.handled)
}
@Test
fun decodeResponse_readsHandledPresenceResult() {
val response =
NodePresenceAliveBeacon.decodeResponse(
"""{"ok":true,"event":"node.presence.alive","handled":true,"reason":"persisted"}""",
)
assertEquals(true, response?.ok)
assertEquals("node.presence.alive", response?.event)
assertEquals(true, response?.handled)
assertEquals("persisted", response?.reason)
}
@Test
fun decodeResponse_rejectsOversizedPayloadBeforeParsing() {
assertNull(
NodePresenceAliveBeacon.decodeResponse("""{"ok":true,"reason":"${"x".repeat(16 * 1024)}"}"""),
)
}
@Test
fun sanitizeReasonForLog_removesControlCharactersAndBoundsLength() {
val raw = "bad\nreason\t${"x".repeat(240)}"
val sanitized = NodePresenceAliveBeacon.sanitizeReasonForLog(raw)
assertFalse(sanitized.contains("\n"))
assertFalse(sanitized.contains("\t"))
assertEquals(200, sanitized.length)
}
}

View File

@@ -107,6 +107,17 @@ After the first successful pairing, Android auto-reconnects on launch:
- Manual endpoint (if enabled), otherwise
- The last discovered gateway (best-effort).
### Presence alive beacons
After the authenticated node session connects, and when the app moves to the background while the
foreground service is still connected, Android calls `node.event` with
`event: "node.presence.alive"`. The gateway records this as `lastSeenAtMs`/`lastSeenReason` on the
paired node/device metadata only after the authenticated node device identity is known.
The app counts the beacon as successfully recorded only when the gateway response includes
`handled: true`. Older gateways may acknowledge `node.event` with `{ "ok": true }`; that response is
compatible but does not count as a durable last-seen update.
### 4) Approve pairing (CLI)
On the gateway machine: