From 60e6ccdb8c00a82b0f410cd520ed835c32b3a273 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sun, 24 May 2026 18:05:16 +0530 Subject: [PATCH] fix(android): smooth gateway pairing recovery --- .../ai/openclaw/app/ui/GatewayPairingRetry.kt | 6 +- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 111 ++++++++++++++++-- .../app/ui/OnboardingFlowLogicTest.kt | 61 ++++++++++ 3 files changed, 164 insertions(+), 14 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt index 9dad4c11f06..f0af089cd36 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayPairingRetry.kt @@ -12,7 +12,8 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import kotlinx.coroutines.delay -internal const val PAIRING_AUTO_RETRY_MS = 6_000L +internal const val PAIRING_INITIAL_AUTO_RETRY_MS = 1_500L +internal const val PAIRING_AUTO_RETRY_MS = 4_000L @Composable internal fun PairingAutoRetryEffect( @@ -40,9 +41,10 @@ internal fun PairingAutoRetryEffect( if (!enabled || !lifecycleStarted) { return@LaunchedEffect } + delay(PAIRING_INITIAL_AUTO_RETRY_MS) while (true) { - delay(PAIRING_AUTO_RETRY_MS) onRetry() + delay(PAIRING_AUTO_RETRY_MS) } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt index 439b29c90bf..baa244bf38a 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/OnboardingFlow.kt @@ -23,6 +23,7 @@ import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorManager import android.os.Build +import android.os.SystemClock import android.provider.Settings import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult @@ -79,6 +80,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -100,6 +102,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import kotlinx.coroutines.delay private enum class OnboardingStep { Welcome, @@ -108,6 +111,8 @@ private enum class OnboardingStep { Permissions, } +private const val GATEWAY_CONNECT_SETTLING_MS = 2_500L + @Composable fun OnboardingFlow( viewModel: MainViewModel, @@ -134,6 +139,8 @@ fun OnboardingFlow( var password by rememberSaveable { mutableStateOf("") } var setupError by rememberSaveable { mutableStateOf(null) } var attemptedConnect by rememberSaveable { mutableStateOf(false) } + var connectAttemptStartedAtMs by rememberSaveable { mutableLongStateOf(0L) } + var recoveryNowMs by remember { mutableLongStateOf(SystemClock.elapsedRealtime()) } val qrScannerOptions = remember { @@ -152,6 +159,13 @@ fun OnboardingFlow( } } + LaunchedEffect(step, connectAttemptStartedAtMs) { + if (step != OnboardingStep.Recovery || connectAttemptStartedAtMs <= 0L) return@LaunchedEffect + recoveryNowMs = SystemClock.elapsedRealtime() + delay(GATEWAY_CONNECT_SETTLING_MS) + recoveryNowMs = SystemClock.elapsedRealtime() + } + pendingTrust?.let { prompt -> AlertDialog( onDismissRequest = viewModel::declineGatewayTrustPrompt, @@ -250,6 +264,7 @@ fun OnboardingFlow( setupError = null attemptedConnect = true + connectAttemptStartedAtMs = SystemClock.elapsedRealtime() viewModel.resetGatewaySetupAuth() viewModel.setManualEnabled(true) viewModel.setManualHost(config.host) @@ -275,10 +290,12 @@ fun OnboardingFlow( remoteAddress = remoteAddress, ready = ready, attemptedConnect = attemptedConnect, + connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS, onAutoRetry = viewModel::refreshGatewayConnection, onBack = { step = OnboardingStep.Gateway }, onRetry = { attemptedConnect = true + connectAttemptStartedAtMs = SystemClock.elapsedRealtime() val config = resolveGatewayConfig( setupCode = setupCode, @@ -496,6 +513,7 @@ private fun GatewayRecoveryScreen( remoteAddress: String?, ready: Boolean, attemptedConnect: Boolean, + connectSettling: Boolean, onAutoRetry: () -> Unit, onBack: () -> Unit, onRetry: () -> Unit, @@ -503,9 +521,9 @@ private fun GatewayRecoveryScreen( onContinue: () -> Unit, modifier: Modifier = Modifier, ) { - val pairingRequired = gatewayStatusLooksLikePairing(statusText) + val recoveryState = gatewayRecoveryUiState(ready = ready, statusText = statusText, connectSettling = connectSettling) val context = LocalContext.current - PairingAutoRetryEffect(enabled = pairingRequired && attemptedConnect && !ready, onRetry = onAutoRetry) + PairingAutoRetryEffect(enabled = recoveryState.canAutoRetry && attemptedConnect, onRetry = onAutoRetry) ClawScaffold(modifier = modifier, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 16.dp)) { Column(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(18.dp)) { @@ -513,14 +531,26 @@ private fun GatewayRecoveryScreen( Spacer(modifier = Modifier.height(12.dp)) Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { Icon( - imageVector = if (ready) Icons.Default.CheckCircle else Icons.Default.ErrorOutline, + imageVector = + when (recoveryState) { + GatewayRecoveryUiState.Connected -> Icons.Default.CheckCircle + GatewayRecoveryUiState.Pairing -> Icons.Default.WifiTethering + GatewayRecoveryUiState.Finishing -> Icons.Default.WifiTethering + GatewayRecoveryUiState.Failed -> Icons.Default.ErrorOutline + }, contentDescription = null, modifier = Modifier.size(64.dp), - tint = if (ready) ClawTheme.colors.success else ClawTheme.colors.warning, + tint = + when (recoveryState) { + GatewayRecoveryUiState.Connected -> ClawTheme.colors.success + GatewayRecoveryUiState.Pairing -> ClawTheme.colors.text + GatewayRecoveryUiState.Finishing -> ClawTheme.colors.text + GatewayRecoveryUiState.Failed -> ClawTheme.colors.warning + }, ) - Text(text = if (ready) "Connected" else "Connection failed", style = ClawTheme.type.display, color = ClawTheme.colors.text) + Text(text = recoveryState.title, style = ClawTheme.type.display, color = ClawTheme.colors.text) Text( - text = if (ready) "Your Gateway is ready." else "We could not reach your Gateway.\nLet's fix this.", + text = recoveryState.message, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, textAlign = TextAlign.Center, @@ -534,18 +564,30 @@ private fun GatewayRecoveryScreen( Text(text = recoveryGatewayDetail(ready = ready, remoteAddress = remoteAddress, statusText = statusText), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) ClawStatusPill( text = - when { - ready -> "Healthy" - pairingRequired -> "Pairing" - else -> "Needs attention" + when (recoveryState) { + GatewayRecoveryUiState.Connected -> "Healthy" + GatewayRecoveryUiState.Pairing -> "Pairing" + GatewayRecoveryUiState.Finishing -> "Connecting" + GatewayRecoveryUiState.Failed -> "Needs attention" + }, + status = + when (recoveryState) { + GatewayRecoveryUiState.Connected -> ClawStatus.Success + GatewayRecoveryUiState.Pairing -> ClawStatus.Neutral + GatewayRecoveryUiState.Finishing -> ClawStatus.Neutral + GatewayRecoveryUiState.Failed -> ClawStatus.Warning }, - status = if (ready) ClawStatus.Success else ClawStatus.Warning, ) } } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - ClawPrimaryButton(text = if (ready) "Continue" else "Retry connection", icon = if (ready) Icons.Default.CheckCircle else Icons.Default.Refresh, onClick = if (ready) onContinue else onRetry, modifier = Modifier.fillMaxWidth()) + ClawPrimaryButton( + text = if (ready) "Continue" else "Retry connection", + icon = if (ready) Icons.Default.CheckCircle else Icons.Default.Refresh, + onClick = if (ready) onContinue else onRetry, + modifier = Modifier.fillMaxWidth(), + ) OutlinedAction(title = "Edit connection", icon = Icons.Default.Edit, onClick = onEdit) OutlinedAction(title = "Copy diagnostic", icon = Icons.Default.ContentCopy, onClick = { copyGatewayDiagnostic(context, statusText, serverName, remoteAddress, ready) }) } @@ -824,6 +866,51 @@ private fun PermissionContinueButton(onClick: () -> Unit) { } } +internal enum class GatewayRecoveryUiState( + val title: String, + val message: String, + val canAutoRetry: Boolean, +) { + Connected( + title = "Connected", + message = "Your Gateway is ready.", + canAutoRetry = false, + ), + Pairing( + title = "Pairing Gateway", + message = "Approval is in progress.\nOpenClaw will reconnect automatically.", + canAutoRetry = true, + ), + Finishing( + title = "Finishing Setup", + message = "Gateway approved this phone.\nOpenClaw is bringing the node online.", + canAutoRetry = true, + ), + Failed( + title = "Connection issue", + message = "We could not reach your Gateway.\nLet's fix this.", + canAutoRetry = false, + ), +} + +internal fun gatewayRecoveryUiState( + ready: Boolean, + statusText: String, + connectSettling: Boolean, +): GatewayRecoveryUiState = + when { + ready -> GatewayRecoveryUiState.Connected + connectSettling -> GatewayRecoveryUiState.Finishing + gatewayStatusLooksLikePairing(statusText) -> GatewayRecoveryUiState.Pairing + gatewayStatusLooksLikePartialConnect(statusText) -> GatewayRecoveryUiState.Finishing + else -> GatewayRecoveryUiState.Failed + } + +internal fun gatewayStatusLooksLikePartialConnect(statusText: String): Boolean { + val lower = gatewayStatusForDisplay(statusText).lowercase() + return lower.contains("operator offline") || lower.contains("node offline") +} + private data class GatewayConfig( val host: String, val port: Int, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt index e824678a5a6..c62c474cc1c 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/OnboardingFlowLogicTest.kt @@ -1,5 +1,6 @@ package ai.openclaw.app.ui +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -24,4 +25,64 @@ class OnboardingFlowLogicTest { fun allowsFinishOnlyWhenOperatorAndNodeAreConnected() { assertTrue(canFinishOnboarding(isConnected = true, isNodeConnected = true)) } + + @Test + fun showsPairingStateForPairingRequiredGatewayStatus() { + assertEquals( + GatewayRecoveryUiState.Pairing, + gatewayRecoveryUiState( + ready = false, + statusText = "Gateway error: pairing required; approval in progress", + connectSettling = false, + ), + ) + } + + @Test + fun showsConnectedStateWhenGatewayBecomesReady() { + assertEquals( + GatewayRecoveryUiState.Connected, + gatewayRecoveryUiState( + ready = true, + statusText = "Gateway error: pairing required", + connectSettling = false, + ), + ) + } + + @Test + fun showsFinishingStateWhileGatewayConnectionSettles() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayRecoveryUiState( + ready = false, + statusText = "Offline", + connectSettling = true, + ), + ) + } + + @Test + fun showsFinishingStateForPartialGatewayConnection() { + assertEquals( + GatewayRecoveryUiState.Finishing, + gatewayRecoveryUiState( + ready = false, + statusText = "Connected (node offline)", + connectSettling = false, + ), + ) + } + + @Test + fun showsConnectionIssueForNonPairingFailure() { + assertEquals( + GatewayRecoveryUiState.Failed, + gatewayRecoveryUiState( + ready = false, + statusText = "Gateway error: connection refused", + connectSettling = false, + ), + ) + } }