mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
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:
committed by
GitHub
parent
c788aa025e
commit
04e774eeac
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ?: ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user