fix(android): smooth gateway pairing recovery

This commit is contained in:
Ayaan Zaidi
2026-05-24 18:05:16 +05:30
parent 6d9b3887ea
commit 60e6ccdb8c
3 changed files with 164 additions and 14 deletions

View File

@@ -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)
}
}
}

View File

@@ -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<String?>(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,

View File

@@ -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,
),
)
}
}