Skip to content

Commit 1c7229d

Browse files
authored
fix(android): expose exact gateway recovery actions (#99414)
* fix(android): expose exact gateway recovery actions * fix(android): keep auth recovery actionable * fix(android): cover terminal gateway auth states * fix(android): keep recovery commands current * fix(android): reset gateway-scoped recovery state * fix(android): keep auth rate limits terminal * fix(android): expire approval ids before refresh * chore(i18n): refresh Android recovery inventory
1 parent cd6b675 commit 1c7229d

15 files changed

Lines changed: 1003 additions & 247 deletions

apps/.i18n/native-source.json

Lines changed: 177 additions & 73 deletions
Large diffs are not rendered by default.

apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,8 @@ class MainViewModel(
113113

114114
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
115115
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
116-
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> =
117-
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
116+
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> =
117+
runtimeState(initial = GatewayNodeCapabilityApproval.Loading) { it.nodeCapabilityApproval }
118118
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
119119
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
120120
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> =

apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
1414
import ai.openclaw.app.gateway.GatewayTlsProbeResult
1515
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
1616
import ai.openclaw.app.gateway.NodeEventSendOutcome
17+
import ai.openclaw.app.gateway.normalizeGatewayApprovalRequestId
1718
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
1819
import ai.openclaw.app.gateway.parseChatSendAck
1920
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
@@ -82,6 +83,7 @@ import java.util.concurrent.ConcurrentHashMap
8283
import java.util.concurrent.atomic.AtomicLong
8384

8485
private const val MAX_PENDING_NOTIFICATION_EVENTS = 128
86+
private const val NODE_APPROVAL_COMMAND_FRESH_MS = 30_000L
8587

8688
internal data class PendingNotificationNodeEvent(
8789
val event: String,
@@ -488,8 +490,8 @@ class NodeRuntime(
488490
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
489491
private val _nodeConnected = MutableStateFlow(false)
490492
val nodeConnected: StateFlow<Boolean> = _nodeConnected.asStateFlow()
491-
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
492-
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
493+
private val _nodeCapabilityApproval = MutableStateFlow<GatewayNodeCapabilityApproval>(GatewayNodeCapabilityApproval.Loading)
494+
val nodeCapabilityApproval: StateFlow<GatewayNodeCapabilityApproval> = _nodeCapabilityApproval.asStateFlow()
493495

494496
private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, "Offline", null))
495497
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> = _gatewayConnectionDisplay.asStateFlow()
@@ -1893,6 +1895,7 @@ class NodeRuntime(
18931895
) {
18941896
// A user-selected connect target must never inherit notification content from another gateway.
18951897
notificationOutbox.clear()
1898+
invalidateNodeCapabilityApprovalState()
18961899
val connectAttemptId = connectAttemptSeq.incrementAndGet()
18971900
_pendingGatewayTrust.value = null
18981901
val tls = connectionManager.resolveTlsParams(endpoint)
@@ -2432,7 +2435,16 @@ class NodeRuntime(
24322435
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
24332436
_nodesDevicesRefreshing.value = true
24342437
_nodesDevicesErrorText.value = null
2435-
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
2438+
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
2439+
val pendingFallback = _nodeCapabilityApproval.value.withoutExactRequestId()
2440+
if (pendingFallback != null) {
2441+
_nodeCapabilityApproval.value = pendingFallback
2442+
} else if (
2443+
_nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingApproval &&
2444+
_nodeCapabilityApproval.value !is GatewayNodeCapabilityApproval.PendingReapproval
2445+
) {
2446+
_nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading
2447+
}
24362448
}
24372449
if (!refreshStarted) return
24382450
if (!operatorConnected) {
@@ -2451,18 +2463,19 @@ class NodeRuntime(
24512463
val nodesRes = operatorSession.request("node.list", "{}")
24522464
val nodesRoot = json.parseToJsonElement(nodesRes).asObjectOrNull()
24532465
val nodes = parseGatewayNodes(nodesRoot?.get("nodes") as? JsonArray)
2454-
val approvalState =
2455-
currentNodeCapabilityApprovalState(
2466+
val approval =
2467+
currentNodeCapabilityApproval(
24562468
nodes = nodes,
24572469
selfNodeId = identityStore.loadOrCreate().deviceId,
24582470
)
24592471
val publishedApproval =
24602472
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2461-
_nodeCapabilityApprovalState.value = approvalState
2473+
_nodeCapabilityApproval.value = approval
24622474
}
24632475
if (!publishedApproval) {
24642476
return
24652477
}
2478+
scheduleNodeApprovalCommandRefresh(refreshGeneration, approval)
24662479
val devicesRoot =
24672480
try {
24682481
val devicesRes = operatorSession.request("device.pair.list", "{}")
@@ -2490,6 +2503,26 @@ class NodeRuntime(
24902503
}
24912504
}
24922505

2506+
private fun scheduleNodeApprovalCommandRefresh(
2507+
refreshGeneration: Long,
2508+
approval: GatewayNodeCapabilityApproval,
2509+
) {
2510+
val fallback = approval.withoutExactRequestId() ?: return
2511+
scope.launch {
2512+
delay(NODE_APPROVAL_COMMAND_FRESH_MS)
2513+
// Pairing request IDs expire on the Gateway. Age out cached commands before rechecking so
2514+
// recovery never leaves an old exact ID visible when a refresh fails or races disconnect.
2515+
val shouldRefresh =
2516+
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2517+
_nodeCapabilityApproval.value = fallback
2518+
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
2519+
}
2520+
if (shouldRefresh && operatorConnected) {
2521+
refreshNodesDevicesFromGateway()
2522+
}
2523+
}
2524+
}
2525+
24932526
private suspend fun refreshExecApprovalsFromGateway() {
24942527
val refreshGeneration = execApprovalsRefreshSeq.incrementAndGet()
24952528
_execApprovalsRefreshing.value = true
@@ -2683,7 +2716,8 @@ class NodeRuntime(
26832716
private fun invalidateNodeCapabilityApprovalState() {
26842717
val refreshGeneration = nodeApprovalRefreshGuard.begin()
26852718
nodeApprovalRefreshGuard.publishIfCurrent(refreshGeneration) {
2686-
_nodeCapabilityApprovalState.value = GatewayNodeApprovalState.Loading
2719+
_nodeCapabilityApproval.value = GatewayNodeCapabilityApproval.Loading
2720+
_nodesDevicesSummary.value = _nodesDevicesSummary.value.withoutExactApprovalRequestIds()
26872721
_nodesDevicesRefreshing.value = false
26882722
}
26892723
}
@@ -3476,6 +3510,36 @@ enum class GatewayNodeApprovalState {
34763510
Unapproved,
34773511
}
34783512

3513+
/** Current phone approval state; only pending variants can carry an approval target. */
3514+
sealed interface GatewayNodeCapabilityApproval {
3515+
data object Loading : GatewayNodeCapabilityApproval
3516+
3517+
data object Unsupported : GatewayNodeCapabilityApproval
3518+
3519+
data object Approved : GatewayNodeCapabilityApproval
3520+
3521+
data class PendingApproval(
3522+
val requestId: String?,
3523+
) : GatewayNodeCapabilityApproval
3524+
3525+
data class PendingReapproval(
3526+
val requestId: String?,
3527+
) : GatewayNodeCapabilityApproval
3528+
3529+
data object Unapproved : GatewayNodeCapabilityApproval
3530+
}
3531+
3532+
internal fun GatewayNodeCapabilityApproval.withoutExactRequestId(): GatewayNodeCapabilityApproval? =
3533+
when (this) {
3534+
is GatewayNodeCapabilityApproval.PendingApproval ->
3535+
requestId?.let { GatewayNodeCapabilityApproval.PendingApproval(requestId = null) }
3536+
is GatewayNodeCapabilityApproval.PendingReapproval ->
3537+
requestId?.let { GatewayNodeCapabilityApproval.PendingReapproval(requestId = null) }
3538+
else -> null
3539+
}
3540+
3541+
internal fun GatewayNodesDevicesSummary.withoutExactApprovalRequestIds(): GatewayNodesDevicesSummary = copy(nodes = nodes.map { node -> node.copy(pendingRequestId = null) })
3542+
34793543
/** Prevents older node.list responses from overwriting newer approval state. */
34803544
internal class GatewayNodeApprovalRefreshGuard {
34813545
private val lock = Any()
@@ -3508,14 +3572,26 @@ internal fun parseGatewayNodeApprovalState(raw: String?): GatewayNodeApprovalSta
35083572
else -> GatewayNodeApprovalState.Loading
35093573
}
35103574

3511-
internal fun currentNodeCapabilityApprovalState(
3575+
internal fun currentNodeCapabilityApproval(
35123576
nodes: List<GatewayNodeSummary>,
35133577
selfNodeId: String,
3514-
): GatewayNodeApprovalState =
3515-
nodes
3516-
.firstOrNull { it.id == selfNodeId }
3517-
?.approvalState
3518-
?: GatewayNodeApprovalState.Loading
3578+
): GatewayNodeCapabilityApproval {
3579+
val node = nodes.firstOrNull { it.id == selfNodeId } ?: return GatewayNodeCapabilityApproval.Loading
3580+
return when (node.approvalState) {
3581+
GatewayNodeApprovalState.Loading -> GatewayNodeCapabilityApproval.Loading
3582+
GatewayNodeApprovalState.Unsupported -> GatewayNodeCapabilityApproval.Unsupported
3583+
GatewayNodeApprovalState.Approved -> GatewayNodeCapabilityApproval.Approved
3584+
GatewayNodeApprovalState.PendingApproval ->
3585+
GatewayNodeCapabilityApproval.PendingApproval(
3586+
normalizeGatewayApprovalRequestId(node.pendingRequestId),
3587+
)
3588+
GatewayNodeApprovalState.PendingReapproval ->
3589+
GatewayNodeCapabilityApproval.PendingReapproval(
3590+
normalizeGatewayApprovalRequestId(node.pendingRequestId),
3591+
)
3592+
GatewayNodeApprovalState.Unapproved -> GatewayNodeCapabilityApproval.Unapproved
3593+
}
3594+
}
35193595

35203596
internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
35213597
val obj = item.asObjectOrNull() ?: return null
@@ -3536,7 +3612,7 @@ internal fun parseGatewayNodeSummary(item: JsonElement): GatewayNodeSummary? {
35363612
} else {
35373613
GatewayNodeApprovalState.Unsupported
35383614
},
3539-
pendingRequestId = obj["pendingRequestId"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() },
3615+
pendingRequestId = normalizeGatewayApprovalRequestId(obj["pendingRequestId"].asStringOrNull()),
35403616
capabilities = parseGatewayStringArray(obj["caps"] as? JsonArray),
35413617
commands = parseGatewayStringArray(obj["commands"] as? JsonArray),
35423618
)

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewaySession.kt

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ data class GatewayConnectErrorDetails(
8585
val minimumProbeProtocol: Int? = null,
8686
)
8787

88+
private val gatewayApprovalRequestIdPattern = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
89+
90+
/** Keeps copied approval commands single-argument and safe for a gateway host shell. */
91+
internal fun normalizeGatewayApprovalRequestId(requestId: String?): String? {
92+
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
93+
return trimmed.takeIf { gatewayApprovalRequestIdPattern.matches(it) }
94+
}
95+
8896
/**
8997
* Server hello fields cached by the Android runtime after a successful connect.
9098
*/
@@ -144,7 +152,6 @@ class GatewaySession(
144152
private companion object {
145153
// Keep connect timeout above observed gateway unauthorized close on lower-end devices.
146154
private const val CONNECT_RPC_TIMEOUT_MS = 12_000L
147-
private val PAIRING_REQUEST_ID_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
148155
}
149156

150157
/**
@@ -944,7 +951,7 @@ class GatewaySession(
944951
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
945952
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
946953
reason = it["reason"].asStringOrNull(),
947-
requestId = normalizePairingRequestId(it["requestId"].asStringOrNull()),
954+
requestId = normalizeGatewayApprovalRequestId(it["requestId"].asStringOrNull()),
948955
retryable = it["retryable"].asBooleanOrNull() == true,
949956
clientMinProtocol = it["clientMinProtocol"].asIntOrNull(),
950957
clientMaxProtocol = it["clientMaxProtocol"].asIntOrNull(),
@@ -975,11 +982,6 @@ class GatewaySession(
975982
onEvent(event, payloadJson)
976983
}
977984

978-
private fun normalizePairingRequestId(requestId: String?): String? {
979-
val trimmed = requestId?.trim()?.takeIf { it.isNotEmpty() } ?: return null
980-
return trimmed.takeIf { PAIRING_REQUEST_ID_PATTERN.matches(it) }
981-
}
982-
983985
private suspend fun awaitConnectNonce(): String =
984986
try {
985987
withTimeout(2_000) { connectNonceDeferred.await() }
@@ -1310,7 +1312,6 @@ class GatewaySession(
13101312
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
13111313
role = target?.options?.role,
13121314
scopes = target?.options?.scopes ?: emptyList(),
1313-
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
13141315
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
13151316
)
13161317
}
@@ -1334,33 +1335,52 @@ internal fun shouldPauseGatewayReconnectAfterAuthFailure(
13341335
hasBootstrapToken: Boolean,
13351336
role: String?,
13361337
scopes: List<String>,
1337-
deviceTokenRetryBudgetUsed: Boolean,
13381338
pendingDeviceTokenRetry: Boolean,
1339-
): Boolean =
1340-
when (error.details?.code) {
1339+
): Boolean {
1340+
val details = error.details
1341+
val code = details?.code
1342+
if (code == "PAIRING_REQUIRED") {
1343+
val pairingDetails = details
1344+
return !(
1345+
hasBootstrapToken &&
1346+
role?.trim() == "node" &&
1347+
scopes.isEmpty() &&
1348+
pairingDetails.reason == "not-paired" &&
1349+
(
1350+
pairingDetails.pauseReconnect == false ||
1351+
pairingDetails.recommendedNextStep == "wait_then_retry"
1352+
)
1353+
)
1354+
}
1355+
// Gateway rate limits last minutes; generic retry advice must not trigger the short reconnect loop.
1356+
if (code == "AUTH_RATE_LIMITED") return true
1357+
when (details?.recommendedNextStep) {
1358+
"wait_then_retry" -> return false
1359+
"retry_with_device_token" -> return !pendingDeviceTokenRetry
1360+
"update_auth_configuration",
1361+
"update_auth_credentials",
1362+
"review_auth_configuration",
1363+
-> return true
1364+
}
1365+
return when (code) {
13411366
"AUTH_TOKEN_MISSING",
1367+
"AUTH_TOKEN_NOT_CONFIGURED",
1368+
"AUTH_DEVICE_TOKEN_MISMATCH",
13421369
"AUTH_BOOTSTRAP_TOKEN_INVALID",
13431370
"AUTH_PASSWORD_MISSING",
13441371
"AUTH_PASSWORD_MISMATCH",
1345-
"AUTH_RATE_LIMITED",
1372+
"AUTH_PASSWORD_NOT_CONFIGURED",
1373+
"AUTH_SCOPE_MISMATCH",
13461374
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
13471375
"DEVICE_IDENTITY_REQUIRED",
13481376
-> true
1349-
"PAIRING_REQUIRED" ->
1350-
!(
1351-
hasBootstrapToken &&
1352-
role?.trim() == "node" &&
1353-
scopes.isEmpty() &&
1354-
error.details.reason == "not-paired" &&
1355-
(
1356-
error.details.pauseReconnect == false ||
1357-
error.details.recommendedNextStep == "wait_then_retry"
1358-
)
1359-
)
1360-
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
1377+
// The first shared-token mismatch may schedule one trusted stored-device-token retry.
1378+
// Once no retry is pending, keep the terminal recovery action visible until credentials change.
1379+
"AUTH_TOKEN_MISMATCH" -> !pendingDeviceTokenRetry
13611380
"PROTOCOL_MISMATCH" -> true
13621381
else -> false
13631382
}
1383+
}
13641384

13651385
/** Builds the gateway WebSocket URL from endpoint authority and TLS policy. */
13661386
internal fun buildGatewayWebSocketUrl(

0 commit comments

Comments
 (0)