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:
Masato Hoshino
2026-07-02 06:50:26 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent 0ccdef5dcf
commit 40d4e32110
17 changed files with 773 additions and 364 deletions
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,
)
}