mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(android): show specific gateway auth-recovery reason instead of generic label (#98698)
* fix(android): distinguish gateway auth-recovery reasons instead of one generic label gatewaySummary()/gatewayStatusLabel() collapsed every auth failure (expired setup QR, missing/invalid token, missing/invalid password, stale device identity) into "Authentication needed", discarding the already-computed MainViewModel.gatewayConnectionProblem signal consumed elsewhere in the app (OnboardingFlow's recoveryGatewayAuthDetail). Thread it into both status labels via a shared gatewayAuthNeededSummary() helper so the Overview and Settings screens tell the user which recovery action applies. Fixes #98046 * test(android): lock gateway auth-needed labels to the real unauthorized message format Verified against src/gateway/server/ws-connection/auth-messages.ts: formatGatewayAuthFailureMessage always returns strings starting with "unauthorized", which contains the "auth" substring the status-text gate checks for. Add tests using the exact real-world message text (not just synthetic "auth failed" strings) so this stays regression-locked. * fix(android): widen the auth-needed status gate to cover device-identity failures CONTROL_UI_DEVICE_IDENTITY_REQUIRED/DEVICE_IDENTITY_REQUIRED real gateway messages ("device identity required", "control ui requires device identity...") don't contain "auth", so the status.contains("auth") gate never reached their specific label. Add "device identity" as an additional substring the gate checks for those two codes, with regression tests using the exact real message text (message-handler.ts). Verified via GitHub-style codex review round 2 (round 1's "unauthorized doesn't contain auth" claim was checked against source and rejected as false — "unauthorized" does contain "auth" — but this second, narrower finding about device-identity codes held up under the same source check). * fix(android): centralize gateway auth status labels * fix(android): correlate gateway connection diagnostics * chore(android): align native i18n inventory after rebase * fix(android): preserve retryable pairing guidance --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
0ccdef5dcf
commit
40d4e32110
+247
-247
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,8 @@ class MainViewModel(
|
||||
runtimeState(initial = GatewayNodeApprovalState.Loading) { it.nodeCapabilityApprovalState }
|
||||
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||
val gatewayConnectionProblem: StateFlow<GatewayConnectionProblem?> = runtimeState(initial = null) { it.gatewayConnectionProblem }
|
||||
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> =
|
||||
runtimeState(initial = GatewayConnectionDisplay(false, "Offline", null)) { it.gatewayConnectionDisplay }
|
||||
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
|
||||
|
||||
@@ -36,21 +36,20 @@ class NodeForegroundService : Service() {
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
// Split connection and capture flows before combining so notification text
|
||||
// Keep the connection tuple atomic, then split connection and capture work so notification text
|
||||
// can update without restarting runtime-owned connection work.
|
||||
notificationJob =
|
||||
scope.launch {
|
||||
combine(
|
||||
combine(
|
||||
runtime.statusText,
|
||||
runtime.gatewayConnectionDisplay,
|
||||
runtime.serverName,
|
||||
runtime.isConnected,
|
||||
runtime.voiceCaptureMode,
|
||||
) { status, server, connected, mode ->
|
||||
) { connection, server, mode ->
|
||||
VoiceNotificationBase(
|
||||
status = status,
|
||||
status = connection.statusText,
|
||||
server = server,
|
||||
connected = connected,
|
||||
connected = connection.isConnected,
|
||||
mode = mode,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -106,6 +106,48 @@ data class GatewayConnectionProblem(
|
||||
)
|
||||
}
|
||||
|
||||
data class GatewayConnectionDisplay(
|
||||
val isConnected: Boolean,
|
||||
val statusText: String,
|
||||
val problem: GatewayConnectionProblem?,
|
||||
)
|
||||
|
||||
private fun gatewayProblemAfterDisconnect(
|
||||
problem: GatewayConnectionProblem?,
|
||||
statusText: String,
|
||||
): GatewayConnectionProblem? =
|
||||
// Automatic bootstrap pairing retries need their approval guidance until success or a different failure.
|
||||
problem?.takeIf { statusText == "Reconnecting…" && it.canAutoRetry }
|
||||
|
||||
internal fun gatewayConnectionDisplay(
|
||||
operatorConnected: Boolean,
|
||||
nodeConnected: Boolean,
|
||||
operatorStatusText: String,
|
||||
nodeStatusText: String,
|
||||
operatorProblem: GatewayConnectionProblem?,
|
||||
nodeProblem: GatewayConnectionProblem?,
|
||||
): GatewayConnectionDisplay {
|
||||
val operator = operatorStatusText.trim()
|
||||
val node = nodeStatusText.trim()
|
||||
return when {
|
||||
operatorConnected && nodeConnected -> GatewayConnectionDisplay(true, "Connected", null)
|
||||
operatorConnected -> GatewayConnectionDisplay(true, "Connected (node offline)", nodeProblem)
|
||||
nodeConnected ->
|
||||
GatewayConnectionDisplay(
|
||||
isConnected = false,
|
||||
statusText =
|
||||
if (operator.isNotEmpty() && operator != "Offline") {
|
||||
"Connected (operator: $operator)"
|
||||
} else {
|
||||
"Connected (operator offline)"
|
||||
},
|
||||
problem = operatorProblem,
|
||||
)
|
||||
operator.isNotBlank() && operator != "Offline" -> GatewayConnectionDisplay(false, operator, operatorProblem)
|
||||
else -> GatewayConnectionDisplay(false, node, nodeProblem)
|
||||
}
|
||||
}
|
||||
|
||||
class NodeRuntime(
|
||||
context: Context,
|
||||
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||
@@ -313,6 +355,8 @@ class NodeRuntime(
|
||||
private val _nodeCapabilityApprovalState = MutableStateFlow(GatewayNodeApprovalState.Loading)
|
||||
val nodeCapabilityApprovalState: StateFlow<GatewayNodeApprovalState> = _nodeCapabilityApprovalState.asStateFlow()
|
||||
|
||||
private val _gatewayConnectionDisplay = MutableStateFlow(GatewayConnectionDisplay(false, "Offline", null))
|
||||
val gatewayConnectionDisplay: StateFlow<GatewayConnectionDisplay> = _gatewayConnectionDisplay.asStateFlow()
|
||||
private val _statusText = MutableStateFlow("Offline")
|
||||
val statusText: StateFlow<String> = _statusText.asStateFlow()
|
||||
private val _gatewayConnectionProblem = MutableStateFlow<GatewayConnectionProblem?>(null)
|
||||
@@ -445,6 +489,9 @@ class NodeRuntime(
|
||||
private var operatorConnected = false
|
||||
private var operatorStatusText: String = "Offline"
|
||||
private var nodeStatusText: String = "Offline"
|
||||
private var operatorConnectionProblem: GatewayConnectionProblem? = null
|
||||
private var nodeConnectionProblem: GatewayConnectionProblem? = null
|
||||
private val gatewayStatusLock = Any()
|
||||
|
||||
private val operatorSession =
|
||||
GatewaySession(
|
||||
@@ -452,16 +499,17 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = { hello ->
|
||||
_gatewayConnectionProblem.value = null
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
_serverName.value = hello.serverName
|
||||
_remoteAddress.value = hello.remoteAddress
|
||||
_gatewayVersion.value = hello.serverVersion
|
||||
_gatewayUpdateAvailable.value = hello.updateAvailable
|
||||
_seamColorArgb.value = DEFAULT_SEAM_COLOR_ARGB
|
||||
syncMainSessionKey(resolveAgentIdFromMainSessionKey(hello.mainSessionKey))
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
operatorConnectionProblem = null
|
||||
operatorConnected = true
|
||||
operatorStatusText = "Connected"
|
||||
}
|
||||
micCapture.onGatewayConnectionChanged(true)
|
||||
scope.launch {
|
||||
subscribeOperatorSessionEvents()
|
||||
@@ -473,9 +521,7 @@ class NodeRuntime(
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
invalidateNodeCapabilityApprovalState()
|
||||
operatorStatusText = message
|
||||
_serverName.value = null
|
||||
_remoteAddress.value = null
|
||||
_gatewayVersion.value = null
|
||||
@@ -505,10 +551,18 @@ class NodeRuntime(
|
||||
_healthLogsSummary.value = GatewayHealthLogsSummary()
|
||||
chat.applyMainSessionKey(resolveMainSessionKey())
|
||||
chat.onDisconnected(message)
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
operatorConnected = false
|
||||
operatorStatusText = message
|
||||
operatorConnectionProblem = gatewayProblemAfterDisconnect(operatorConnectionProblem, message)
|
||||
}
|
||||
micCapture.onGatewayConnectionChanged(false)
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onConnectFailure = { error, pauseReconnect ->
|
||||
updateStatus {
|
||||
operatorConnectionProblem = gatewayConnectionProblem(error, pauseReconnect)
|
||||
}
|
||||
},
|
||||
onEvent = { event, payloadJson ->
|
||||
handleGatewayEvent(event, payloadJson)
|
||||
},
|
||||
@@ -528,14 +582,15 @@ class NodeRuntime(
|
||||
identityStore = identityStore,
|
||||
deviceAuthStore = deviceAuthStore,
|
||||
onConnected = {
|
||||
_gatewayConnectionProblem.value = null
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
nodeConnectionProblem = null
|
||||
_nodeConnected.value = true
|
||||
nodeStatusText = "Connected"
|
||||
}
|
||||
showLocalCanvasOnConnect()
|
||||
publishNodePresenceAliveBeacon(NodePresenceAliveBeacon.Trigger.Connect)
|
||||
val endpoint = connectedEndpoint
|
||||
@@ -547,17 +602,23 @@ class NodeRuntime(
|
||||
}
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
_nodeConnected.value = false
|
||||
invalidateNodeCapabilityApprovalState()
|
||||
nodeStatusText = message
|
||||
didAutoRequestCanvasRehydrate = false
|
||||
_canvasA2uiHydrated.value = false
|
||||
_canvasRehydratePending.value = false
|
||||
_canvasRehydrateErrorText.value = null
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
_nodeConnected.value = false
|
||||
nodeStatusText = message
|
||||
nodeConnectionProblem = gatewayProblemAfterDisconnect(nodeConnectionProblem, message)
|
||||
}
|
||||
showLocalCanvasOnDisconnect()
|
||||
},
|
||||
onConnectFailure = ::handleGatewayConnectFailure,
|
||||
onConnectFailure = { error, pauseReconnect ->
|
||||
updateStatus {
|
||||
nodeConnectionProblem = gatewayConnectionProblem(error, pauseReconnect)
|
||||
}
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
|
||||
@@ -591,7 +652,7 @@ class NodeRuntime(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
isConnected = { _isConnected.value },
|
||||
isConnected = { gatewayConnectionDisplay.value.isConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
).also { speaker ->
|
||||
@@ -702,7 +763,7 @@ class NodeRuntime(
|
||||
context = appContext,
|
||||
scope = scope,
|
||||
session = operatorSession,
|
||||
isConnected = { _isConnected.value },
|
||||
isConnected = { gatewayConnectionDisplay.value.isConnected },
|
||||
onBeforeSpeak = { micCapture.pauseForTts() },
|
||||
onAfterSpeak = { micCapture.resumeAfterTts() },
|
||||
onStoppedByRelay = { finishTalkModeAfterRelayClose() },
|
||||
@@ -736,45 +797,56 @@ class NodeRuntime(
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun updateStatus() {
|
||||
_isConnected.value = operatorConnected
|
||||
val operator = operatorStatusText.trim()
|
||||
val node = nodeStatusText.trim()
|
||||
_statusText.value =
|
||||
when {
|
||||
operatorConnected && _nodeConnected.value -> "Connected"
|
||||
operatorConnected && !_nodeConnected.value -> "Connected (node offline)"
|
||||
!operatorConnected && _nodeConnected.value ->
|
||||
if (operator.isNotEmpty() && operator != "Offline") {
|
||||
"Connected (operator: $operator)"
|
||||
} else {
|
||||
"Connected (operator offline)"
|
||||
}
|
||||
operator.isNotBlank() && operator != "Offline" -> operator
|
||||
else -> node
|
||||
}
|
||||
private fun updateStatus(update: () -> Unit = {}) {
|
||||
synchronized(gatewayStatusLock) {
|
||||
update()
|
||||
// Select and publish text plus diagnostics atomically; operator and node callbacks run concurrently.
|
||||
val display =
|
||||
gatewayConnectionDisplay(
|
||||
operatorConnected = operatorConnected,
|
||||
nodeConnected = _nodeConnected.value,
|
||||
operatorStatusText = operatorStatusText,
|
||||
nodeStatusText = nodeStatusText,
|
||||
operatorProblem = operatorConnectionProblem,
|
||||
nodeProblem = nodeConnectionProblem,
|
||||
)
|
||||
_gatewayConnectionDisplay.value = display
|
||||
_isConnected.value = display.isConnected
|
||||
_statusText.value = display.statusText
|
||||
_gatewayConnectionProblem.value = display.problem
|
||||
}
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun handleGatewayConnectFailure(
|
||||
private fun setStandaloneGatewayStatus(statusText: String) {
|
||||
synchronized(gatewayStatusLock) {
|
||||
val display = GatewayConnectionDisplay(operatorConnected, statusText, null)
|
||||
_gatewayConnectionDisplay.value = display
|
||||
_isConnected.value = display.isConnected
|
||||
_statusText.value = display.statusText
|
||||
_gatewayConnectionProblem.value = display.problem
|
||||
}
|
||||
updateHomeCanvasState()
|
||||
}
|
||||
|
||||
private fun gatewayConnectionProblem(
|
||||
error: GatewaySession.ErrorShape,
|
||||
pauseReconnect: Boolean,
|
||||
) {
|
||||
): GatewayConnectionProblem {
|
||||
val details = error.details
|
||||
_gatewayConnectionProblem.value =
|
||||
GatewayConnectionProblem(
|
||||
code = details?.code ?: error.code,
|
||||
message = error.message,
|
||||
reason = details?.reason,
|
||||
requestId = details?.requestId,
|
||||
recommendedNextStep = details?.recommendedNextStep,
|
||||
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
|
||||
retryable = details?.retryable == true,
|
||||
clientMinProtocol = details?.clientMinProtocol,
|
||||
clientMaxProtocol = details?.clientMaxProtocol,
|
||||
expectedProtocol = details?.expectedProtocol,
|
||||
minimumProbeProtocol = details?.minimumProbeProtocol,
|
||||
)
|
||||
return GatewayConnectionProblem(
|
||||
code = details?.code ?: error.code,
|
||||
message = error.message,
|
||||
reason = details?.reason,
|
||||
requestId = details?.requestId,
|
||||
recommendedNextStep = details?.recommendedNextStep,
|
||||
pauseReconnect = pauseReconnect || details?.pauseReconnect == true,
|
||||
retryable = details?.retryable == true,
|
||||
clientMinProtocol = details?.clientMinProtocol,
|
||||
clientMaxProtocol = details?.clientMaxProtocol,
|
||||
expectedProtocol = details?.expectedProtocol,
|
||||
minimumProbeProtocol = details?.minimumProbeProtocol,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveMainSessionKey(): String {
|
||||
@@ -1130,7 +1202,7 @@ class NodeRuntime(
|
||||
|
||||
private fun autoConnectIfNeeded() {
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
if (gatewayConnectionDisplay.value.isConnected) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
// Only attempt the stored preferred gateway once per runtime lifetime; users
|
||||
// can still reconnect explicitly from the UI after a failed auto attempt.
|
||||
@@ -1139,7 +1211,7 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
private fun reconnectPreferredGatewayOnForeground() {
|
||||
if (_isConnected.value) return
|
||||
if (gatewayConnectionDisplay.value.isConnected) return
|
||||
if (_pendingGatewayTrust.value != null) return
|
||||
if (connectedEndpoint != null) {
|
||||
refreshGatewayConnection()
|
||||
@@ -1353,7 +1425,7 @@ class NodeRuntime(
|
||||
if (!BuildConfig.DEBUG) {
|
||||
throw IllegalStateException("voice e2e is debug-only")
|
||||
}
|
||||
if (!_isConnected.value) {
|
||||
if (!gatewayConnectionDisplay.value.isConnected) {
|
||||
throw IllegalStateException("gateway not connected")
|
||||
}
|
||||
if (!hasRecordAudioPermission()) {
|
||||
@@ -1538,12 +1610,14 @@ class NodeRuntime(
|
||||
if (endpoint == null) {
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
?: run {
|
||||
_statusText.value = "Failed: no saved gateway endpoint"
|
||||
setStandaloneGatewayStatus("Failed: no saved gateway endpoint")
|
||||
}
|
||||
return
|
||||
}
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
operatorStatusText = "Connecting…"
|
||||
operatorConnectionProblem = null
|
||||
}
|
||||
connectWithAuth(endpoint = endpoint, auth = resolveGatewayConnectAuth(), reconnect = true)
|
||||
}
|
||||
|
||||
@@ -1565,10 +1639,12 @@ class NodeRuntime(
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
)
|
||||
if (operatorAuth == null) {
|
||||
operatorConnected = false
|
||||
operatorStatusText = "Offline"
|
||||
updateStatus {
|
||||
operatorConnected = false
|
||||
operatorStatusText = "Offline"
|
||||
operatorConnectionProblem = null
|
||||
}
|
||||
operatorSession.disconnect()
|
||||
updateStatus()
|
||||
} else {
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
@@ -1607,14 +1683,14 @@ class NodeRuntime(
|
||||
tls.expectedFingerprint
|
||||
?.let(::normalizeGatewayTlsFingerprint)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
_statusText.value = "Verify gateway TLS fingerprint…"
|
||||
setStandaloneGatewayStatus("Verify gateway TLS fingerprint…")
|
||||
scope.launch {
|
||||
val tlsProbe = tlsFingerprintProbe(endpoint.host, endpoint.port)
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return@launch
|
||||
val fp =
|
||||
tlsProbe.fingerprintSha256 ?: run {
|
||||
if (expectedFingerprint == null) {
|
||||
_statusText.value = gatewayTlsProbeFailureMessage(tlsProbe.failure)
|
||||
setStandaloneGatewayStatus(gatewayTlsProbeFailureMessage(tlsProbe.failure))
|
||||
} else {
|
||||
connectAfterTlsCheck(endpoint = endpoint, auth = auth, connectAttemptId = connectAttemptId)
|
||||
}
|
||||
@@ -1651,11 +1727,13 @@ class NodeRuntime(
|
||||
connectAttemptId: Long,
|
||||
) {
|
||||
if (!isCurrentConnectAttempt(connectAttemptId)) return
|
||||
_gatewayConnectionProblem.value = null
|
||||
connectedEndpoint = endpoint
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
operatorConnectionProblem = null
|
||||
nodeConnectionProblem = null
|
||||
operatorStatusText = "Connecting…"
|
||||
nodeStatusText = "Connecting…"
|
||||
}
|
||||
connectWithAuth(endpoint = endpoint, auth = auth)
|
||||
}
|
||||
|
||||
@@ -1687,7 +1765,7 @@ class NodeRuntime(
|
||||
|
||||
fun declineGatewayTrustPrompt() {
|
||||
_pendingGatewayTrust.value = null
|
||||
_statusText.value = "Offline"
|
||||
setStandaloneGatewayStatus("Offline")
|
||||
}
|
||||
|
||||
private fun gatewayTlsProbeFailureMessage(failure: GatewayTlsProbeFailure?): String =
|
||||
@@ -1710,7 +1788,7 @@ class NodeRuntime(
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port <= 0 || port > 65535) {
|
||||
_statusText.value = "Failed: invalid manual host/port"
|
||||
setStandaloneGatewayStatus("Failed: invalid manual host/port")
|
||||
return
|
||||
}
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
@@ -1733,8 +1811,10 @@ class NodeRuntime(
|
||||
auth = auth,
|
||||
storedOperatorToken = loadStoredRoleDeviceToken("operator"),
|
||||
) ?: return
|
||||
operatorStatusText = "Connecting…"
|
||||
updateStatus()
|
||||
updateStatus {
|
||||
operatorStatusText = "Connecting…"
|
||||
operatorConnectionProblem = null
|
||||
}
|
||||
operatorSession.connect(
|
||||
endpoint,
|
||||
operatorAuth.token,
|
||||
@@ -1750,7 +1830,10 @@ class NodeRuntime(
|
||||
stopActiveVoiceSession()
|
||||
connectedEndpoint = null
|
||||
activeGatewayAuth = null
|
||||
_gatewayConnectionProblem.value = null
|
||||
updateStatus {
|
||||
operatorConnectionProblem = null
|
||||
nodeConnectionProblem = null
|
||||
}
|
||||
_pendingGatewayTrust.value = null
|
||||
operatorSession.disconnect()
|
||||
nodeSession.disconnect()
|
||||
@@ -1954,7 +2037,7 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
if (!gatewayConnectionDisplay.value.isConnected) return
|
||||
try {
|
||||
val res = operatorSession.request("config.get", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
@@ -2926,9 +3009,10 @@ class NodeRuntime(
|
||||
}
|
||||
|
||||
private fun resolveHomeCanvasGatewayState(): HomeCanvasGatewayState {
|
||||
val lower = _statusText.value.trim().lowercase()
|
||||
val display = gatewayConnectionDisplay.value
|
||||
val lower = display.statusText.trim().lowercase()
|
||||
return when {
|
||||
_isConnected.value -> HomeCanvasGatewayState.Connected
|
||||
display.isConnected -> HomeCanvasGatewayState.Connected
|
||||
lower.contains("connecting") || lower.contains("reconnecting") -> HomeCanvasGatewayState.Connecting
|
||||
lower.contains("error") || lower.contains("failed") -> HomeCanvasGatewayState.Error
|
||||
else -> HomeCanvasGatewayState.Offline
|
||||
|
||||
@@ -67,9 +67,10 @@ private enum class ConnectInputMode {
|
||||
@Composable
|
||||
fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val statusText = gatewayConnectionDisplay.statusText
|
||||
val gatewayConnectionProblem = gatewayConnectionDisplay.problem
|
||||
val isConnected = gatewayConnectionDisplay.isConnected
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -43,6 +44,22 @@ internal fun gatewayStatusLooksLikePairing(statusText: String): Boolean {
|
||||
return lower.contains("pair") || lower.contains("approve")
|
||||
}
|
||||
|
||||
/** Maps structured gateway auth failures to the compact labels used by status surfaces. */
|
||||
internal fun gatewayAuthRecoveryLabel(problem: GatewayConnectionProblem?): String? =
|
||||
when (problem?.code) {
|
||||
"AUTH_BOOTSTRAP_TOKEN_INVALID" -> "Setup code expired"
|
||||
"AUTH_TOKEN_MISSING" -> "Gateway token needed"
|
||||
"AUTH_PASSWORD_MISSING" -> "Gateway password needed"
|
||||
"AUTH_PASSWORD_MISMATCH" -> "Gateway password invalid"
|
||||
"AUTH_TOKEN_MISMATCH",
|
||||
"AUTH_DEVICE_TOKEN_MISMATCH",
|
||||
-> "Saved auth invalid"
|
||||
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
|
||||
"DEVICE_IDENTITY_REQUIRED",
|
||||
-> "Device identity required"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Builds the copyable support prompt with device, endpoint, and exact status context. */
|
||||
internal fun buildGatewayDiagnosticsReport(
|
||||
screen: String,
|
||||
|
||||
@@ -41,10 +41,10 @@ internal fun HealthLogsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val isConnected = gatewayConnectionDisplay.isConnected
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val chatHealthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val modelCount by viewModel.modelCatalog.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val talkStatus by viewModel.talkModeStatusText.collectAsState()
|
||||
@@ -82,7 +82,7 @@ internal fun HealthLogsSettingsScreen(
|
||||
),
|
||||
)
|
||||
HealthStatusPanel(
|
||||
gateway = statusText,
|
||||
gateway = gatewayConnectionDisplay.statusText,
|
||||
node = if (isNodeConnected) "Online" else "Waiting",
|
||||
chat = if (chatHealthOk) "Ready" else "Needs connection",
|
||||
models = "${modelCount.size} available",
|
||||
|
||||
@@ -139,9 +139,10 @@ fun OnboardingFlow(
|
||||
val onboardingDark = appearanceThemeMode.isDark(systemDark = isSystemInDarkTheme())
|
||||
ClawDesignTheme(dark = onboardingDark) {
|
||||
val context = LocalContext.current
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val statusText = gatewayConnectionDisplay.statusText
|
||||
val gatewayConnectionProblem = gatewayConnectionDisplay.problem
|
||||
val isConnected = gatewayConnectionDisplay.isConnected
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val nodeCapabilityApprovalState by viewModel.nodeCapabilityApprovalState.collectAsState()
|
||||
val runtimeInitialized by viewModel.runtimeInitialized.collectAsState()
|
||||
|
||||
@@ -104,14 +104,13 @@ fun PostOnboardingTabs(
|
||||
}
|
||||
}
|
||||
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
|
||||
val statusVisual =
|
||||
remember(statusText, isConnected) {
|
||||
val lower = statusText.lowercase()
|
||||
remember(gatewayConnectionDisplay) {
|
||||
val lower = gatewayConnectionDisplay.statusText.lowercase()
|
||||
when {
|
||||
isConnected -> StatusVisual.Connected
|
||||
gatewayConnectionDisplay.isConnected -> StatusVisual.Connected
|
||||
lower.contains("connecting") || lower.contains("reconnecting") -> StatusVisual.Connecting
|
||||
lower.contains("pairing") || lower.contains("approval") || lower.contains("auth") -> StatusVisual.Warning
|
||||
lower.contains("error") || lower.contains("failed") -> StatusVisual.Error
|
||||
@@ -129,7 +128,7 @@ fun PostOnboardingTabs(
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
topBar = {
|
||||
TopStatusBar(
|
||||
statusText = statusText,
|
||||
statusText = gatewayConnectionDisplay.statusText,
|
||||
statusVisual = statusVisual,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -3,6 +3,8 @@ package ai.openclaw.app.ui
|
||||
import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayConnectionDisplay
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.GatewayCronJobSummary
|
||||
import ai.openclaw.app.GatewayExecApprovalSummary
|
||||
import ai.openclaw.app.GatewayUsageProviderSummary
|
||||
@@ -885,9 +887,8 @@ private fun GatewaySettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val isNodeConnected by viewModel.isNodeConnected.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
@@ -909,11 +910,14 @@ private fun GatewaySettingsScreen(
|
||||
SettingsMetricPanel(
|
||||
rows =
|
||||
listOf(
|
||||
SettingsMetric("Connection", if (isConnected) "Connected" else "Offline"),
|
||||
SettingsMetric("Connection", if (gatewayConnectionDisplay.isConnected) "Connected" else "Offline"),
|
||||
SettingsMetric("Node", if (isNodeConnected) "Online" else "Not paired"),
|
||||
SettingsMetric("Gateway", serverName?.takeIf { it.isNotBlank() } ?: "Home Gateway"),
|
||||
SettingsMetric("Address", remoteAddress?.takeIf { it.isNotBlank() } ?: "Not available"),
|
||||
SettingsMetric("Status", gatewayStatusLabel(statusText = statusText, isConnected = isConnected)),
|
||||
SettingsMetric(
|
||||
"Status",
|
||||
gatewayStatusLabel(gatewayConnectionDisplay),
|
||||
),
|
||||
),
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
@@ -1044,16 +1048,17 @@ internal fun appearanceThemeOptions(): List<String> = AppearanceThemeMode.entrie
|
||||
internal fun appearanceThemeModeForLabel(label: String): AppearanceThemeMode = AppearanceThemeMode.fromDisplayLabel(label)
|
||||
|
||||
/** Converts raw gateway connection text into stable settings metric labels. */
|
||||
private fun gatewayStatusLabel(
|
||||
internal fun gatewayStatusLabel(
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem? = null,
|
||||
): String {
|
||||
if (isConnected) return "Ready"
|
||||
val status = statusText.trim().lowercase()
|
||||
return when {
|
||||
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
|
||||
status.contains("pair") -> "Pairing needed"
|
||||
status.contains("auth") -> "Authentication needed"
|
||||
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: "Authentication needed"
|
||||
status.contains("fingerprint verification timed out") -> "TLS timed out"
|
||||
status.contains("no tls endpoint") -> "No TLS endpoint"
|
||||
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
|
||||
@@ -1063,6 +1068,8 @@ private fun gatewayStatusLabel(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewayStatusLabel(display: GatewayConnectionDisplay): String = gatewayStatusLabel(display.statusText, display.isConnected, display.problem)
|
||||
|
||||
@Composable
|
||||
private fun AboutSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
||||
@@ -3,6 +3,8 @@ package ai.openclaw.app.ui
|
||||
import ai.openclaw.app.BuildConfig
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayConnectionDisplay
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.GatewayDreamingSummary
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
@@ -333,10 +335,10 @@ private fun OverviewScreen(
|
||||
onOpenSettingsRoute: (SettingsRoute) -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val isConnected = gatewayConnectionDisplay.isConnected
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val execApprovals by viewModel.execApprovals.collectAsState()
|
||||
@@ -409,8 +411,8 @@ private fun OverviewScreen(
|
||||
OverviewPrimaryPanel(
|
||||
agentName = activeAgentName,
|
||||
agentBadge = activeAgentBadge,
|
||||
statusText = gatewaySummary(statusText, isConnected),
|
||||
isConnected = isConnected,
|
||||
statusText = gatewaySummary(gatewayConnectionDisplay),
|
||||
isConnected = gatewayConnectionDisplay.isConnected,
|
||||
pendingRunCount = pendingRunCount,
|
||||
sessionCount = sessions.size,
|
||||
cronJobCount = cronStatus.jobs,
|
||||
@@ -1339,8 +1341,8 @@ private fun SettingsShellScreen(
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val isConnected = gatewayConnectionDisplay.isConnected
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val cameraEnabled by viewModel.cameraEnabled.collectAsState()
|
||||
@@ -1412,7 +1414,13 @@ private fun SettingsShellScreen(
|
||||
|
||||
val settingsRows =
|
||||
listOf(
|
||||
SettingsRow("Gateway", gatewaySummary(statusText, isConnected), Icons.Default.Cloud, status = isConnected, route = SettingsRoute.Gateway),
|
||||
SettingsRow(
|
||||
"Gateway",
|
||||
gatewaySummary(gatewayConnectionDisplay),
|
||||
Icons.Default.Cloud,
|
||||
status = gatewayConnectionDisplay.isConnected,
|
||||
route = SettingsRoute.Gateway,
|
||||
),
|
||||
SettingsRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, status = nodesDevicesStatus(nodesDevicesSummary), route = SettingsRoute.NodesDevices),
|
||||
SettingsRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, status = channelsStatus(channelsSummary), route = SettingsRoute.Channels),
|
||||
SettingsRow("Agents", if (agents.isEmpty()) "Load from gateway" else "${agents.size} available", Icons.Default.Person, status = agents.isNotEmpty(), route = SettingsRoute.Agents),
|
||||
@@ -1769,19 +1777,22 @@ private fun relativeSessionTime(updatedAtMs: Long): String {
|
||||
|
||||
private fun displaySessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
|
||||
|
||||
private fun gatewaySummary(
|
||||
internal fun gatewaySummary(
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
gatewayConnectionProblem: GatewayConnectionProblem? = null,
|
||||
): String {
|
||||
if (isConnected) return "Online and ready"
|
||||
val status = statusText.trim().lowercase()
|
||||
return when {
|
||||
status.contains("connecting") || status.contains("reconnecting") -> "Connecting..."
|
||||
status.contains("pairing") -> "Waiting for pairing"
|
||||
status.contains("auth") -> "Authentication needed"
|
||||
status.contains("auth") || status.contains("device identity") -> gatewayAuthRecoveryLabel(gatewayConnectionProblem) ?: "Authentication needed"
|
||||
status.contains("fingerprint verification timed out") -> "TLS timed out"
|
||||
status.contains("no tls endpoint") -> "No TLS endpoint"
|
||||
status.contains("certificate") || status.contains("tls") -> "Certificate review needed"
|
||||
else -> "Not connected"
|
||||
}
|
||||
}
|
||||
|
||||
internal fun gatewaySummary(display: GatewayConnectionDisplay): String = gatewaySummary(display.statusText, display.isConnected, display.problem)
|
||||
|
||||
@@ -99,8 +99,7 @@ fun ChatScreen(
|
||||
val errorText by viewModel.chatError.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val healthOk by viewModel.chatHealthOk.collectAsState()
|
||||
val gatewayConnected by viewModel.isConnected.collectAsState()
|
||||
val gatewayConnectionProblem by viewModel.gatewayConnectionProblem.collectAsState()
|
||||
val gatewayConnectionDisplay by viewModel.gatewayConnectionDisplay.collectAsState()
|
||||
val sessionKey by viewModel.chatSessionKey.collectAsState()
|
||||
val mainSessionKey by viewModel.mainSessionKey.collectAsState()
|
||||
val thinkingLevel by viewModel.chatThinkingLevel.collectAsState()
|
||||
@@ -109,16 +108,15 @@ fun ChatScreen(
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val chatDraft by viewModel.chatDraft.collectAsState()
|
||||
val pendingAssistantAutoSend by viewModel.pendingAssistantAutoSend.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val remoteAddress by viewModel.remoteAddress.collectAsState()
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val contextUsage = resolveChatContextUsage(sessionKey = sessionKey, mainSessionKey = mainSessionKey, sessions = sessions)
|
||||
val gatewayAddress = gatewayDiagnosticsEndpoint(remoteAddress = remoteAddress, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls)
|
||||
val gatewayProblemMessage = gatewayConnectionProblem?.message?.takeIf { it.isNotBlank() }
|
||||
val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: statusText)
|
||||
val gatewayOffline = !gatewayConnected
|
||||
val gatewayProblemMessage = gatewayConnectionDisplay.problem?.message?.takeIf { it.isNotBlank() }
|
||||
val offlineStatus = gatewayStatusForDisplay(gatewayProblemMessage ?: gatewayConnectionDisplay.statusText)
|
||||
val gatewayOffline = !gatewayConnectionDisplay.isConnected
|
||||
val context = LocalContext.current
|
||||
val resolver = context.contentResolver
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -200,7 +198,10 @@ fun ChatScreen(
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error = error, gatewayConnected = gatewayConnected))
|
||||
ChatNotice(
|
||||
title = "Chat needs attention",
|
||||
body = userFacingChatError(error = error, gatewayConnected = gatewayConnectionDisplay.isConnected),
|
||||
)
|
||||
}
|
||||
|
||||
ChatMessageList(
|
||||
|
||||
@@ -2,6 +2,7 @@ package ai.openclaw.app
|
||||
|
||||
import ai.openclaw.app.gateway.DeviceAuthStore
|
||||
import ai.openclaw.app.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.app.gateway.GatewayConnectErrorDetails
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import ai.openclaw.app.gateway.GatewayTlsProbeFailure
|
||||
@@ -30,6 +31,94 @@ import java.util.UUID
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [34])
|
||||
class GatewayBootstrapAuthTest {
|
||||
@Test
|
||||
fun standaloneStatusPreservesLiveOperatorConnection() {
|
||||
val runtime = NodeRuntime(RuntimeEnvironment.getApplication())
|
||||
writeField(runtime, "operatorConnected", true)
|
||||
val method = runtime.javaClass.getDeclaredMethod("setStandaloneGatewayStatus", String::class.java)
|
||||
method.isAccessible = true
|
||||
|
||||
method.invoke(runtime, "Verify gateway TLS fingerprint…")
|
||||
|
||||
assertTrue(runtime.gatewayConnectionDisplay.value.isConnected)
|
||||
assertEquals("Verify gateway TLS fingerprint…", runtime.gatewayConnectionDisplay.value.statusText)
|
||||
assertNull(runtime.gatewayConnectionDisplay.value.problem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unstructuredRetryClearsEarlierOperatorAuthProblem() {
|
||||
val runtime = NodeRuntime(RuntimeEnvironment.getApplication())
|
||||
val session = readField<GatewaySession>(runtime, "operatorSession")
|
||||
val onDisconnected = readField<(String) -> Unit>(session, "onDisconnected")
|
||||
val onConnectFailure = readField<(GatewaySession.ErrorShape, Boolean) -> Unit>(session, "onConnectFailure")
|
||||
|
||||
onDisconnected("Gateway error: unauthorized")
|
||||
onConnectFailure(
|
||||
GatewaySession.ErrorShape(
|
||||
code = "UNAUTHORIZED",
|
||||
message = "unauthorized",
|
||||
details =
|
||||
GatewayConnectErrorDetails(
|
||||
code = "AUTH_TOKEN_MISSING",
|
||||
canRetryWithDeviceToken = false,
|
||||
recommendedNextStep = "provide_token",
|
||||
),
|
||||
),
|
||||
true,
|
||||
)
|
||||
val problemCode =
|
||||
runtime.gatewayConnectionDisplay.value.problem
|
||||
?.code
|
||||
assertEquals(
|
||||
"AUTH_TOKEN_MISSING",
|
||||
problemCode,
|
||||
)
|
||||
|
||||
onDisconnected("Reconnecting…")
|
||||
assertEquals("Reconnecting…", runtime.gatewayConnectionDisplay.value.statusText)
|
||||
assertNull(runtime.gatewayConnectionDisplay.value.problem)
|
||||
|
||||
onDisconnected("Gateway error: timeout")
|
||||
assertEquals("Gateway error: timeout", runtime.gatewayConnectionDisplay.value.statusText)
|
||||
assertNull(runtime.gatewayConnectionDisplay.value.problem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryableNodePairingProblemSurvivesReconnectStatus() {
|
||||
val runtime = NodeRuntime(RuntimeEnvironment.getApplication())
|
||||
val session = readField<GatewaySession>(runtime, "nodeSession")
|
||||
val onDisconnected = readField<(String) -> Unit>(session, "onDisconnected")
|
||||
val onConnectFailure = readField<(GatewaySession.ErrorShape, Boolean) -> Unit>(session, "onConnectFailure")
|
||||
|
||||
onDisconnected("Gateway error: pairing required")
|
||||
onConnectFailure(
|
||||
GatewaySession.ErrorShape(
|
||||
code = "NOT_PAIRED",
|
||||
message = "pairing required",
|
||||
details =
|
||||
GatewayConnectErrorDetails(
|
||||
code = "PAIRING_REQUIRED",
|
||||
canRetryWithDeviceToken = false,
|
||||
recommendedNextStep = "wait_then_retry",
|
||||
reason = "not-paired",
|
||||
requestId = "request-1",
|
||||
retryable = true,
|
||||
),
|
||||
),
|
||||
false,
|
||||
)
|
||||
|
||||
onDisconnected("Reconnecting…")
|
||||
|
||||
val reconnectDisplay = runtime.gatewayConnectionDisplay.value
|
||||
assertEquals("Reconnecting…", reconnectDisplay.statusText)
|
||||
assertEquals("PAIRING_REQUIRED", reconnectDisplay.problem?.code)
|
||||
assertEquals("request-1", reconnectDisplay.problem?.requestId)
|
||||
|
||||
onDisconnected("Gateway error: timeout")
|
||||
assertNull(runtime.gatewayConnectionDisplay.value.problem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
|
||||
assertFalse(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package ai.openclaw.app
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayConnectionDisplayTest {
|
||||
@Test
|
||||
fun operatorProblemStaysCorrelatedWhenNodeConnects() {
|
||||
val operatorProblem = problem("AUTH_TOKEN_MISSING")
|
||||
val nodeProblem = problem("DEVICE_IDENTITY_REQUIRED")
|
||||
|
||||
val display =
|
||||
gatewayConnectionDisplay(
|
||||
operatorConnected = false,
|
||||
nodeConnected = true,
|
||||
operatorStatusText = "Gateway error: unauthorized",
|
||||
nodeStatusText = "Connected",
|
||||
operatorProblem = operatorProblem,
|
||||
nodeProblem = nodeProblem,
|
||||
)
|
||||
|
||||
assertEquals("Connected (operator: Gateway error: unauthorized)", display.statusText)
|
||||
assertSame(operatorProblem, display.problem)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nodeProblemIsSelectedWhenOperatorHasNoStatus() {
|
||||
val operatorProblem = problem("AUTH_TOKEN_MISSING")
|
||||
val nodeProblem = problem("DEVICE_IDENTITY_REQUIRED")
|
||||
|
||||
val display =
|
||||
gatewayConnectionDisplay(
|
||||
operatorConnected = false,
|
||||
nodeConnected = false,
|
||||
operatorStatusText = "Offline",
|
||||
nodeStatusText = "Gateway error: device identity required",
|
||||
operatorProblem = operatorProblem,
|
||||
nodeProblem = nodeProblem,
|
||||
)
|
||||
|
||||
assertEquals("Gateway error: device identity required", display.statusText)
|
||||
assertSame(nodeProblem, display.problem)
|
||||
}
|
||||
|
||||
private fun problem(code: String): GatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = code,
|
||||
message = code,
|
||||
reason = null,
|
||||
requestId = null,
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = true,
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class GatewayDiagnosticsTest {
|
||||
@Test
|
||||
fun authRecoveryLabelsComeFromStructuredProblemCodes() {
|
||||
val labels =
|
||||
mapOf(
|
||||
"AUTH_BOOTSTRAP_TOKEN_INVALID" to "Setup code expired",
|
||||
"AUTH_TOKEN_MISSING" to "Gateway token needed",
|
||||
"AUTH_PASSWORD_MISSING" to "Gateway password needed",
|
||||
"AUTH_PASSWORD_MISMATCH" to "Gateway password invalid",
|
||||
"AUTH_TOKEN_MISMATCH" to "Saved auth invalid",
|
||||
"AUTH_DEVICE_TOKEN_MISMATCH" to "Saved auth invalid",
|
||||
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED" to "Device identity required",
|
||||
"DEVICE_IDENTITY_REQUIRED" to "Device identity required",
|
||||
)
|
||||
|
||||
labels.forEach { (code, label) ->
|
||||
assertEquals(label, gatewayAuthRecoveryLabel(authProblem(code)))
|
||||
}
|
||||
assertNull(gatewayAuthRecoveryLabel(authProblem("SOME_UNMAPPED_CODE")))
|
||||
assertNull(gatewayAuthRecoveryLabel(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun endpointPrefersLiveRemoteAddress() {
|
||||
assertEquals(
|
||||
@@ -57,4 +79,15 @@ class GatewayDiagnosticsTest {
|
||||
assertTrue(report.contains("- gateway address: http://10.0.2.2:18789"))
|
||||
assertTrue(report.contains("- status/error: connection refused"))
|
||||
}
|
||||
|
||||
private fun authProblem(code: String) =
|
||||
ai.openclaw.app.GatewayConnectionProblem(
|
||||
code = code,
|
||||
message = "Authentication failed.",
|
||||
reason = null,
|
||||
requestId = null,
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = false,
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -10,4 +11,52 @@ class SettingsScreensTest {
|
||||
assertEquals("Third-party", androidDistributionChannel("thirdParty"))
|
||||
assertEquals("Unknown", androidDistributionChannel(""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayStatusLabelReportsWhichAuthRecoveryAppliesInsteadOfGenericLabel() {
|
||||
assertEquals(
|
||||
"Setup code expired",
|
||||
gatewayStatusLabel(
|
||||
"Gateway error: unauthorized: bootstrap token invalid or expired",
|
||||
isConnected = false,
|
||||
gatewayConnectionProblem = authProblem("AUTH_BOOTSTRAP_TOKEN_INVALID"),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Device identity required",
|
||||
gatewayStatusLabel(
|
||||
"Gateway error: device identity required",
|
||||
isConnected = false,
|
||||
gatewayConnectionProblem = authProblem("DEVICE_IDENTITY_REQUIRED"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayStatusLabelFallsBackToGenericAuthLabelWithoutAKnownReason() {
|
||||
assertEquals("Authentication needed", gatewayStatusLabel("auth failed", isConnected = false, gatewayConnectionProblem = null))
|
||||
assertEquals(
|
||||
"Authentication needed",
|
||||
gatewayStatusLabel("auth failed", isConnected = false, gatewayConnectionProblem = authProblem("SOME_UNMAPPED_CODE")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewayStatusLabelLeavesUnrelatedStatesUnaffectedByConnectionProblem() {
|
||||
val problem = authProblem("AUTH_TOKEN_MISSING")
|
||||
assertEquals("Ready", gatewayStatusLabel("auth failed", isConnected = true, gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING")))
|
||||
assertEquals("Pairing needed", gatewayStatusLabel("Pairing in progress", isConnected = false, gatewayConnectionProblem = problem))
|
||||
assertEquals("Cannot reach gateway", gatewayStatusLabel("Connection failed", isConnected = false, gatewayConnectionProblem = problem))
|
||||
}
|
||||
|
||||
private fun authProblem(code: String): GatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = code,
|
||||
message = "Authentication failed.",
|
||||
reason = null,
|
||||
requestId = null,
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = false,
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import ai.openclaw.app.AppearanceThemeMode
|
||||
import ai.openclaw.app.GatewayAgentSummary
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayConnectionDisplay
|
||||
import ai.openclaw.app.GatewayConnectionProblem
|
||||
import ai.openclaw.app.GatewayNodeApprovalState
|
||||
import ai.openclaw.app.GatewayNodeSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
@@ -485,9 +487,67 @@ class ShellScreenLogicTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewaySummaryUsesStructuredProblemForCurrentAuthFailure() {
|
||||
assertEquals(
|
||||
"Gateway token needed",
|
||||
gatewaySummary(
|
||||
"Gateway error: unauthorized: gateway token missing",
|
||||
isConnected = false,
|
||||
gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING"),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
"Device identity required",
|
||||
gatewaySummary(
|
||||
"Gateway error: device identity required",
|
||||
isConnected = false,
|
||||
gatewayConnectionProblem = authProblem("DEVICE_IDENTITY_REQUIRED"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewaySummaryFallsBackToGenericAuthLabelWithoutAKnownReason() {
|
||||
assertEquals("Authentication needed", gatewaySummary("auth failed", isConnected = false, gatewayConnectionProblem = null))
|
||||
assertEquals("Authentication needed", gatewaySummary("auth failed", isConnected = false, gatewayConnectionProblem = authProblem("SOME_UNMAPPED_CODE")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewaySummaryLeavesUnrelatedStatesUnaffectedByConnectionProblem() {
|
||||
val problem = authProblem("AUTH_TOKEN_MISSING")
|
||||
assertEquals("Online and ready", gatewaySummary("auth failed", isConnected = true, gatewayConnectionProblem = authProblem("AUTH_TOKEN_MISSING")))
|
||||
assertEquals("Connecting...", gatewaySummary("Reconnecting", isConnected = false, gatewayConnectionProblem = problem))
|
||||
assertEquals("Waiting for pairing", gatewaySummary("Pairing in progress", isConnected = false, gatewayConnectionProblem = problem))
|
||||
assertEquals("Certificate review needed", gatewaySummary("TLS handshake failed", isConnected = false, gatewayConnectionProblem = problem))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gatewaySummaryUsesAtomicRetryDisplayAfterAuthFailure() {
|
||||
val retrying =
|
||||
GatewayConnectionDisplay(
|
||||
isConnected = false,
|
||||
statusText = "Reconnecting…",
|
||||
problem = null,
|
||||
)
|
||||
|
||||
assertEquals("Connecting...", gatewaySummary(retrying))
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
|
||||
private fun settingsRow(route: SettingsRoute): SettingsRow = SettingsRow(route.name, "Value", Icons.Default.Settings, route = route)
|
||||
|
||||
private fun authProblem(code: String): GatewayConnectionProblem =
|
||||
GatewayConnectionProblem(
|
||||
code = code,
|
||||
message = "Authentication failed.",
|
||||
reason = null,
|
||||
requestId = null,
|
||||
recommendedNextStep = null,
|
||||
pauseReconnect = false,
|
||||
retryable = false,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user