From ffda2f00a06c888d3907ec07eb0b442ea1fb8d20 Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Thu, 2 Jul 2026 18:53:47 -0400 Subject: [PATCH] fix: keep Android gateway settings save idempotent (#98277) * fix(android): keep gateway settings save idempotent * fix(android): centralize gateway settings auth ownership Co-authored-by: Colin --------- Co-authored-by: Peter Steinberger --- .../java/ai/openclaw/app/MainViewModel.kt | 57 ++-- .../ai/openclaw/app/ui/ConnectTabScreen.kt | 31 +- .../openclaw/app/ui/GatewayConfigResolver.kt | 106 ++++-- .../app/ui/NodesDevicesSettingsScreen.kt | 6 +- .../java/ai/openclaw/app/ui/OnboardingFlow.kt | 64 ++-- .../ai/openclaw/app/ui/SettingsScreens.kt | 100 +++--- .../app/ui/GatewayConfigResolverTest.kt | 321 +++++++++++------- .../app/ui/OnboardingFlowLogicTest.kt | 60 +++- .../ai/openclaw/app/ui/SettingsScreensTest.kt | 18 + 9 files changed, 478 insertions(+), 285 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 288b8dd1c05..3ca3c8c22c0 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -11,6 +11,8 @@ import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary import ai.openclaw.app.node.CameraCaptureManager import ai.openclaw.app.node.CanvasController import ai.openclaw.app.node.SmsManager +import ai.openclaw.app.ui.GatewayConnectPlan +import ai.openclaw.app.ui.GatewaySavedAuthAction import ai.openclaw.app.voice.VoiceConversationEntry import android.app.Application import androidx.lifecycle.AndroidViewModel @@ -279,10 +281,6 @@ class MainViewModel( prefs.setManualTls(value) } - fun setGatewayToken(value: String) { - prefs.setGatewayToken(value) - } - fun setGatewayBootstrapToken(value: String) { prefs.setGatewayBootstrapToken(value) } @@ -304,37 +302,44 @@ class MainViewModel( deviceAuthStore.clearToken(deviceId, "operator") } - fun saveGatewayConfigAndConnect( - host: String, - port: Int, - tls: Boolean, - token: String, - bootstrapToken: String, - password: String, - resetSetupAuth: Boolean, - ) { + internal fun saveGatewayConfigAndConnect(plan: GatewayConnectPlan) { // Gateway pairing touches encrypted prefs, identity files, and sockets; keep // the whole sequence off the Compose thread so retries cannot trigger ANRs. viewModelScope.launch(Dispatchers.Default) { - if (resetSetupAuth) { + val config = plan.config + val replacesSavedAuth = plan.savedAuthAction != GatewaySavedAuthAction.PRESERVE + val hasExplicitAuth = + config.token.isNotEmpty() || config.bootstrapToken.isNotEmpty() || config.password.isNotEmpty() + if (replacesSavedAuth) { resetGatewaySetupAuth() } prefs.setManualEnabled(true) - prefs.setManualHost(host) - prefs.setManualPort(port) - prefs.setManualTls(tls) - prefs.setGatewayBootstrapToken(bootstrapToken) - prefs.setGatewayToken(token) - prefs.setGatewayPassword(password) - ensureRuntime() - .connect( - GatewayEndpoint.manual(host = host, port = port), + prefs.setManualHost(config.host) + prefs.setManualPort(config.port) + prefs.setManualTls(config.tls) + + // A blank same-endpoint save means "keep access". Secrets remain runtime-owned, + // including password-only setups that Compose deliberately cannot read back. + if (replacesSavedAuth || hasExplicitAuth) { + prefs.setGatewayBootstrapToken(config.bootstrapToken) + prefs.setGatewayToken(config.token) + prefs.setGatewayPassword(config.password) + } + + val runtime = ensureRuntime() + val endpoint = GatewayEndpoint.manual(host = config.host, port = config.port) + if (replacesSavedAuth || hasExplicitAuth) { + runtime.connect( + endpoint, NodeRuntime.GatewayConnectAuth( - token = token.ifEmpty { null }, - bootstrapToken = bootstrapToken.ifEmpty { null }, - password = password.ifEmpty { null }, + token = config.token.ifEmpty { null }, + bootstrapToken = config.bootstrapToken.ifEmpty { null }, + password = config.password.ifEmpty { null }, ), ) + } else { + runtime.connect(endpoint) + } } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt index 39e4bdc28ad..65689c47793 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/ConnectTabScreen.kt @@ -77,7 +77,6 @@ fun ConnectTabScreen(viewModel: MainViewModel) { val manualTls by viewModel.manualTls.collectAsState() val manualEnabled by viewModel.manualEnabled.collectAsState() val gatewayToken by viewModel.gatewayToken.collectAsState() - val gatewayBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() var advancedOpen by rememberSaveable { mutableStateOf(false) } @@ -95,6 +94,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) { var manualHostInput by rememberSaveable { mutableStateOf(manualHost.ifBlank { "10.0.2.2" }) } var manualPortInput by rememberSaveable { mutableStateOf(manualPort.toString()) } var manualTlsInput by rememberSaveable { mutableStateOf(manualTls) } + var tokenInput by remember { mutableStateOf("") } var passwordInput by rememberSaveable { mutableStateOf("") } var validationText by rememberSaveable { mutableStateOf(null) } @@ -260,8 +260,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) { return@Button } - val config = - resolveGatewayConnectConfig( + val plan = + resolveGatewayConnectPlan( useSetupCode = inputMode == ConnectInputMode.SetupCode, setupCode = setupCode, savedManualHost = manualHost, @@ -270,12 +270,12 @@ fun ConnectTabScreen(viewModel: MainViewModel) { manualHostInput = manualHostInput, manualPortInput = manualPortInput, manualTlsInput = manualTlsInput, - fallbackBootstrapToken = gatewayBootstrapToken, - fallbackToken = gatewayToken, - fallbackPassword = passwordInput, + bootstrapTokenInput = "", + tokenInput = tokenInput, + passwordInput = passwordInput, ) - if (config == null) { + if (plan == null) { validationText = if (inputMode == ConnectInputMode.SetupCode) { val parsedSetup = decodeGatewaySetupCode(setupCode) @@ -300,15 +300,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } validationText = null - viewModel.saveGatewayConfigAndConnect( - host = config.host, - port = config.port, - tls = config.tls, - token = config.token, - bootstrapToken = config.bootstrapToken, - password = config.password, - resetSetupAuth = inputMode == ConnectInputMode.SetupCode, - ) + viewModel.saveGatewayConfigAndConnect(plan) + tokenInput = "" }, modifier = Modifier.fillMaxWidth().height(52.dp), shape = RoundedCornerShape(14.dp), @@ -538,9 +531,9 @@ fun ConnectTabScreen(viewModel: MainViewModel) { Text(stringResource(R.string.token_optional), style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold), color = mobileTextSecondary) OutlinedTextField( - value = gatewayToken, - onValueChange = { viewModel.setGatewayToken(it) }, - placeholder = { Text("token", style = mobileBody, color = mobileTextTertiary) }, + value = tokenInput, + onValueChange = { tokenInput = it }, + placeholder = { Text("Leave blank to keep saved token", style = mobileBody, color = mobileTextTertiary) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt index 5fe3ef0f650..a5ff936ae21 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayConfigResolver.kt @@ -36,6 +36,19 @@ internal data class GatewayConnectConfig( val password: String, ) +/** How a connection attempt may update credentials already owned by the runtime. */ +internal enum class GatewaySavedAuthAction { + PRESERVE, + REPLACE_ENDPOINT, + REPLACE_SETUP, +} + +/** Endpoint plus the credential ownership decision applied by MainViewModel. */ +internal data class GatewayConnectPlan( + val config: GatewayConnectConfig, + val savedAuthAction: GatewaySavedAuthAction, +) + /** Validation reason used by setup, QR, and manual endpoint copy. */ internal enum class GatewayEndpointValidationError { INVALID_URL, @@ -67,37 +80,38 @@ private const val remoteGatewaySecurityRule = private const val remoteGatewaySecurityFix = "Use a private LAN IP for local setup, or enable Tailscale Serve / expose a wss:// gateway URL for remote access." -/** Resolves setup-code or manual UI fields into a connection config. */ +/** Resolves setup-code or manual UI fields without reading stored credentials. */ internal fun resolveGatewayConnectConfig( useSetupCode: Boolean, setupCode: String, - savedManualHost: String, - savedManualPort: String, - savedManualTls: Boolean, manualHostInput: String, manualPortInput: String, manualTlsInput: Boolean, - fallbackBootstrapToken: String, - fallbackToken: String, - fallbackPassword: String, + bootstrapTokenInput: String, + tokenInput: String, + passwordInput: String, ): GatewayConnectConfig? { if (useSetupCode) { val setup = decodeGatewaySetupCode(setupCode) ?: return null val parsed = parseGatewayEndpointResult(setup.url).config ?: return null - val setupBootstrapToken = setup.bootstrapToken?.trim().orEmpty() + val setupBootstrapToken = + setup.bootstrapToken + ?.trim() + .orEmpty() + .ifEmpty { bootstrapTokenInput.trim() } // Bootstrap setup codes intentionally suppress stale shared credentials; // the bootstrap token owns the first authenticated pairing exchange. val sharedToken = when { !setup.token.isNullOrBlank() -> setup.token.trim() setupBootstrapToken.isNotEmpty() -> "" - else -> fallbackToken.trim() + else -> tokenInput.trim() } val sharedPassword = when { !setup.password.isNullOrBlank() -> setup.password.trim() - setupBootstrapToken.isNotEmpty() -> "" - else -> fallbackPassword.trim() + setupBootstrapToken.isNotEmpty() || sharedToken.isNotEmpty() -> "" + else -> passwordInput.trim() } return GatewayConnectConfig( host = parsed.host, @@ -111,26 +125,70 @@ internal fun resolveGatewayConnectConfig( val manualUrl = composeGatewayManualUrl(manualHostInput, manualPortInput, manualTlsInput) ?: return null val parsed = parseGatewayEndpointResult(manualUrl).config ?: return null - val savedManualEndpoint = - composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls) - ?.let { parseGatewayEndpointResult(it).config } - val preserveBootstrapToken = - savedManualEndpoint != null && - savedManualEndpoint.host == parsed.host && - savedManualEndpoint.port == parsed.port && - savedManualEndpoint.tls == parsed.tls && - fallbackToken.isBlank() && - fallbackPassword.isBlank() + val token = tokenInput.trim() + val bootstrapToken = bootstrapTokenInput.trim().takeIf { token.isEmpty() }.orEmpty() + val password = passwordInput.trim().takeIf { token.isEmpty() && bootstrapToken.isEmpty() }.orEmpty() return GatewayConnectConfig( host = parsed.host, port = parsed.port, tls = parsed.tls, - bootstrapToken = if (preserveBootstrapToken) fallbackBootstrapToken.trim() else "", - token = fallbackToken.trim(), - password = fallbackPassword.trim(), + bootstrapToken = bootstrapToken, + token = token, + password = password, ) } +/** + * Produces one closed endpoint/auth plan. Blank auth fields preserve secrets + * only for the saved endpoint; neither Compose nor this resolver reads them. + */ +internal fun resolveGatewayConnectPlan( + useSetupCode: Boolean, + setupCode: String, + savedManualHost: String, + savedManualPort: String, + savedManualTls: Boolean, + manualHostInput: String, + manualPortInput: String, + manualTlsInput: Boolean, + tokenInput: String, + bootstrapTokenInput: String, + passwordInput: String, +): GatewayConnectPlan? { + val config = + resolveGatewayConnectConfig( + useSetupCode = useSetupCode, + setupCode = setupCode, + manualHostInput = manualHostInput, + manualPortInput = manualPortInput, + manualTlsInput = manualTlsInput, + tokenInput = tokenInput, + bootstrapTokenInput = bootstrapTokenInput, + passwordInput = passwordInput, + ) ?: return null + if (useSetupCode) { + return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP) + } + if (config.bootstrapToken.isNotEmpty()) { + // Bootstrap auth requests a fresh pairing exchange. Retained role tokens + // would otherwise win before the bootstrap credential is attempted. + return GatewayConnectPlan(config, GatewaySavedAuthAction.REPLACE_SETUP) + } + + val savedManualEndpoint = + composeGatewayManualUrl(savedManualHost, savedManualPort, savedManualTls) + ?.let { parseGatewayEndpointResult(it).config } + val action = + if (savedManualEndpoint?.sameEndpoint(config) == true) { + GatewaySavedAuthAction.PRESERVE + } else { + GatewaySavedAuthAction.REPLACE_ENDPOINT + } + return GatewayConnectPlan(config, action) +} + +private fun GatewayEndpointConfig.sameEndpoint(config: GatewayConnectConfig): Boolean = host.equals(config.host, ignoreCase = true) && port == config.port && tls == config.tls + /** Parses an endpoint string and returns only the valid connection config. */ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? = parseGatewayEndpointResult(rawInput).config diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt index 3a91a8ec9a1..2a9ac0f2953 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/NodesDevicesSettingsScreen.kt @@ -99,7 +99,7 @@ private fun NodesDevicesPanel(summary: GatewayNodesDevicesSummary) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { if (!summary.devicePairingAvailable) { ClawPanel { - Text(text = "Device pairing admin needs elevated access. Connected nodes still work.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) + Text(text = devicePairingAdminUnavailableText(), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) } } if (summary.pendingDevices.isNotEmpty()) { @@ -246,6 +246,10 @@ private fun nodeApprovalSubtitle(approvalState: GatewayNodeApprovalState): Strin -> null } +internal fun devicePairingAdminUnavailableText(): String = + "This gateway sign-in can list connected nodes, but it cannot approve new phone pairing. " + + "Pair new phones from a gateway admin session. Node capability approval is separate and still uses nodes approve ." + private fun pendingDeviceSubtitle(device: GatewayPendingDeviceSummary): String { val roles = formatDeviceList(device.roles, "role") val scopes = formatDeviceList(device.scopes, "scope") 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 edfc1a669d5..8cb686f1cfa 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 @@ -151,6 +151,9 @@ fun OnboardingFlow( val gateways by viewModel.gateways.collectAsState() val discoveryStatusText by viewModel.discoveryStatusText.collectAsState() val savedToken by viewModel.gatewayToken.collectAsState() + val savedManualHost by viewModel.manualHost.collectAsState() + val savedManualPort by viewModel.manualPort.collectAsState() + val savedManualTls by viewModel.manualTls.collectAsState() val pendingTrust by viewModel.pendingGatewayTrust.collectAsState() val startAtGatewaySetup by viewModel.startOnboardingAtGatewaySetup.collectAsState() val ready = @@ -186,29 +189,21 @@ fun OnboardingFlow( val permissionState = rememberPermissionState(context = context, viewModel = viewModel) - fun connectToGatewayConfig( - config: GatewayConnectConfig, - resetSetupAuth: Boolean, - ) { + fun connectToGatewayPlan(plan: GatewayConnectPlan) { setupError = null attemptedGatewayName = null attemptedConnect = true connectAttemptStartedAtMs = SystemClock.elapsedRealtime() - viewModel.saveGatewayConfigAndConnect( - host = config.host, - port = config.port, - tls = config.tls, - token = config.token, - bootstrapToken = config.bootstrapToken, - password = config.password, - resetSetupAuth = resetSetupAuth, - ) + viewModel.saveGatewayConfigAndConnect(plan) step = OnboardingStep.Recovery } - fun resolveCurrentGatewayConfig(setupCodeValue: String = setupCode): GatewayConnectConfig? = - resolveOnboardingGatewayConnectConfig( + fun resolveCurrentGatewayPlan(setupCodeValue: String = setupCode): GatewayConnectPlan? = + resolveOnboardingGatewayConnectPlan( setupCode = setupCodeValue, + savedManualHost = savedManualHost, + savedManualPort = savedManualPort.toString(), + savedManualTls = savedManualTls, manualHost = manualHost, manualPort = manualPort, manualTls = manualTls, @@ -320,8 +315,8 @@ fun OnboardingFlow( ) return@addOnSuccessListener } - val config = resolveCurrentGatewayConfig(setupCodeValue = scannedSetupCode) - if (config == null) { + val plan = resolveCurrentGatewayPlan(setupCodeValue = scannedSetupCode) + if (plan == null) { setupError = gatewayEndpointValidationMessage( GatewayEndpointValidationError.INVALID_URL, @@ -330,7 +325,7 @@ fun OnboardingFlow( return@addOnSuccessListener } setupCode = scannedSetupCode - connectToGatewayConfig(config, resetSetupAuth = true) + connectToGatewayPlan(plan) }.addOnFailureListener { setupError = "Could not open the scanner." } }, onSetupCodeChange = { @@ -357,12 +352,12 @@ fun OnboardingFlow( step = OnboardingStep.Recovery }, onPair = { - val config = resolveCurrentGatewayConfig() - if (config == null) { + val plan = resolveCurrentGatewayPlan() + if (plan == null) { setupError = "Enter a setup code or a valid gateway URL." return@GatewaySetupScreen } - connectToGatewayConfig(config, resetSetupAuth = true) + connectToGatewayPlan(plan) }, ) OnboardingStep.Recovery -> @@ -378,8 +373,8 @@ fun OnboardingFlow( connectSettling = recoveryNowMs - connectAttemptStartedAtMs < GATEWAY_CONNECT_SETTLING_MS, onBack = { step = OnboardingStep.Gateway }, onRetry = { - val config = resolveCurrentGatewayConfig() ?: return@GatewayRecoveryScreen - connectToGatewayConfig(config, resetSetupAuth = false) + val plan = resolveCurrentGatewayPlan() ?: return@GatewayRecoveryScreen + connectToGatewayPlan(plan.copy(savedAuthAction = GatewaySavedAuthAction.PRESERVE)) }, onEdit = { step = OnboardingStep.Gateway }, onContinue = { step = OnboardingStep.Permissions }, @@ -1178,27 +1173,30 @@ internal fun recoveryGatewayName( ?.takeIf { it.isNotEmpty() } ?: "Home Gateway" -/** Resolves onboarding setup-code or manual fields into the gateway config used for connect. */ -internal fun resolveOnboardingGatewayConnectConfig( +/** Resolves onboarding setup-code or manual fields into the gateway plan used for connect. */ +internal fun resolveOnboardingGatewayConnectPlan( setupCode: String, + savedManualHost: String, + savedManualPort: String, + savedManualTls: Boolean, manualHost: String, manualPort: String, manualTls: Boolean, token: String, password: String, -): GatewayConnectConfig? = - resolveGatewayConnectConfig( +): GatewayConnectPlan? = + resolveGatewayConnectPlan( useSetupCode = setupCode.isNotBlank(), setupCode = setupCode, - savedManualHost = manualHost, - savedManualPort = manualPort, - savedManualTls = manualTls, + savedManualHost = savedManualHost, + savedManualPort = savedManualPort, + savedManualTls = savedManualTls, manualHostInput = manualHost, manualPortInput = manualPort, manualTlsInput = manualTls, - fallbackBootstrapToken = "", - fallbackToken = token, - fallbackPassword = password, + bootstrapTokenInput = "", + tokenInput = token, + passwordInput = password, ) /** Selects the recovery detail line from endpoint metadata and transient gateway status. */ diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt index de9433137f2..05fbd962438 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt @@ -85,11 +85,13 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.QrCode2 import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -894,8 +896,6 @@ private fun GatewaySettingsScreen( val manualHost by viewModel.manualHost.collectAsState() val manualPort by viewModel.manualPort.collectAsState() val manualTls by viewModel.manualTls.collectAsState() - val savedBootstrapToken by viewModel.gatewayBootstrapToken.collectAsState() - val savedGatewayToken by viewModel.gatewayToken.collectAsState() var setupCode by remember { mutableStateOf("") } var hostInput by remember(manualHost) { mutableStateOf(manualHost.ifBlank { "127.0.0.1" }) } var portInput by remember(manualPort) { mutableStateOf(manualPort.toString()) } @@ -905,6 +905,42 @@ private fun GatewaySettingsScreen( var passwordInput by remember { mutableStateOf("") } var validationText by remember { mutableStateOf(null) } var showSetupCodeHelp by remember { mutableStateOf(false) } + var pendingSetupResetPlan by remember { mutableStateOf(null) } + + fun saveAndConnect(plan: GatewayConnectPlan) { + validationText = null + viewModel.saveGatewayConfigAndConnect(plan) + } + + pendingSetupResetPlan?.let { plan -> + AlertDialog( + onDismissRequest = { pendingSetupResetPlan = null }, + title = { Text("Replace gateway setup?") }, + text = { + Text( + gatewaySettingsSetupResetConfirmationText(), + style = ClawTheme.type.body, + color = ClawTheme.colors.text, + ) + }, + confirmButton = { + TextButton( + onClick = { + pendingSetupResetPlan = null + saveAndConnect(plan) + }, + ) { + Text("Replace setup") + } + }, + dismissButton = { + TextButton(onClick = { pendingSetupResetPlan = null }) { + Text("Cancel") + } + }, + containerColor = ClawTheme.colors.surface, + ) + } SettingsDetailFrame(title = "Gateway", subtitle = "Connection between this phone and OpenClaw.", icon = Icons.Default.Cloud, onBack = onBack) { SettingsMetricPanel( @@ -965,45 +1001,29 @@ private fun GatewaySettingsScreen( ClawPrimaryButton( text = "Save & Connect", onClick = { - val setup = setupCode.trim().takeIf { it.isNotEmpty() }?.let(::decodeGatewaySetupCode) - val endpointConfig = - if (setup != null) { - parseGatewayEndpointResult(setup.url).config - } else { - composeGatewayManualUrl(hostInput, portInput, tlsInput)?.let { parseGatewayEndpointResult(it).config } - } - if (endpointConfig == null) { + val plan = + resolveGatewayConnectPlan( + useSetupCode = setupCode.isNotBlank(), + setupCode = setupCode, + savedManualHost = manualHost, + savedManualPort = manualPort.toString(), + savedManualTls = manualTls, + manualHostInput = hostInput, + manualPortInput = portInput, + manualTlsInput = tlsInput, + tokenInput = tokenInput, + bootstrapTokenInput = bootstrapTokenInput, + passwordInput = passwordInput, + ) + if (plan == null) { validationText = "Enter a valid setup code or gateway address." return@ClawPrimaryButton } - val bootstrapToken = - setup - ?.bootstrapToken - ?.trim() - .orEmpty() - .ifEmpty { bootstrapTokenInput.trim().ifEmpty { savedBootstrapToken } } - val token = - setup - ?.token - ?.trim() - .orEmpty() - .ifEmpty { tokenInput.trim().ifEmpty { if (bootstrapToken.isBlank()) savedGatewayToken else "" } } - val password = - setup - ?.password - ?.trim() - .orEmpty() - .ifEmpty { passwordInput.trim() } - validationText = null - viewModel.saveGatewayConfigAndConnect( - host = endpointConfig.host, - port = endpointConfig.port, - tls = endpointConfig.tls, - token = token, - bootstrapToken = bootstrapToken, - password = password, - resetSetupAuth = setup != null, - ) + if (plan.savedAuthAction == GatewaySavedAuthAction.REPLACE_SETUP) { + pendingSetupResetPlan = plan + } else { + saveAndConnect(plan) + } }, modifier = Modifier.fillMaxWidth(), ) @@ -1119,6 +1139,10 @@ internal fun androidDistributionChannel(flavor: String = BuildConfig.FLAVOR): St else -> flavor.trim() } +internal fun gatewaySettingsSetupResetConfirmationText(): String = + "Replacing the setup code clears this phone's saved setup credentials and device tokens before reconnecting. " + + "This phone may need node capability approval again; continue only when you mean to pair with a fresh gateway setup code." + @Composable private fun AboutStatusRow( title: String, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt index fb128f2728b..0571e0e33e3 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/GatewayConfigResolverTest.kt @@ -407,116 +407,82 @@ class GatewayConfigResolverTest { @Test fun resolveGatewayConnectConfigPrefersBootstrapTokenFromSetupCode() { val setupCode = - encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""") + encodeSetupCode( + """{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""", + ) val resolved = resolveGatewayConnectConfig( useSetupCode = true, setupCode = setupCode, - savedManualHost = "", - savedManualPort = "", - savedManualTls = true, manualHostInput = "", manualPortInput = "", - manualTlsInput = true, - fallbackBootstrapToken = "", - fallbackToken = "shared-token", - fallbackPassword = "shared-password", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "shared-token", + passwordInput = "shared-password", ) assertEquals("gateway.example", resolved?.host) assertEquals(18789, resolved?.port) assertEquals(true, resolved?.tls) assertEquals("bootstrap-1", resolved?.bootstrapToken) - assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) - assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) - } - - @Test - fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { - val setupCode = - encodeSetupCode("""{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""") - - val resolved = - resolveGatewayConnectConfig( - useSetupCode = true, - setupCode = setupCode, - savedManualHost = "", - savedManualPort = "", - savedManualTls = true, - manualHostInput = "", - manualPortInput = "", - manualTlsInput = true, - fallbackBootstrapToken = "", - fallbackToken = "shared-token", - fallbackPassword = "shared-password", - ) - - assertEquals("gateway.example", resolved?.host) - assertEquals(443, resolved?.port) - assertEquals(true, resolved?.tls) - assertEquals("bootstrap-1", resolved?.bootstrapToken) - assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) - assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) - } - - @Test - fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() { - val setupCode = - encodeSetupCode("""{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""") - - val resolved = - resolveGatewayConnectConfig( - useSetupCode = true, - setupCode = setupCode, - savedManualHost = "", - savedManualPort = "", - savedManualTls = false, - manualHostInput = "", - manualPortInput = "", - manualTlsInput = false, - fallbackBootstrapToken = "", - fallbackToken = "shared-token", - fallbackPassword = "shared-password", - ) - - assertEquals("gateway.local", resolved?.host) - assertEquals(18789, resolved?.port) - assertEquals(false, resolved?.tls) - assertEquals("bootstrap-1", resolved?.bootstrapToken) - assertNull(resolved?.token?.takeIf { it.isNotEmpty() }) - assertNull(resolved?.password?.takeIf { it.isNotEmpty() }) - } - - @Test - fun resolveGatewayConnectConfigManualPreservesBootstrapTokenWhenNoReplacementAuthExists() { - val resolved = - resolveGatewayConnectConfig( - useSetupCode = false, - setupCode = "", - savedManualHost = "127.0.0.1", - savedManualPort = "18789", - savedManualTls = false, - manualHostInput = "127.0.0.1", - manualPortInput = "18789", - manualTlsInput = false, - fallbackBootstrapToken = "bootstrap-1", - fallbackToken = "", - fallbackPassword = "", - ) - - assertEquals("127.0.0.1", resolved?.host) - assertEquals(18789, resolved?.port) - assertEquals(false, resolved?.tls) - assertEquals("bootstrap-1", resolved?.bootstrapToken) assertEquals("", resolved?.token) assertEquals("", resolved?.password) } @Test - fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenReplacementPasswordExists() { + fun resolveGatewayConnectConfigDefaultsPortlessWssSetupCodeTo443() { + val setupCode = + encodeSetupCode( + """{"url":"wss://gateway.example","bootstrapToken":"bootstrap-1"}""", + ) + val resolved = resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("gateway.example", resolved?.host) + assertEquals(443, resolved?.port) + assertEquals(true, resolved?.tls) + } + + @Test + fun resolveGatewayConnectConfigAllowsMdnsCleartextSetupCode() { + val setupCode = + encodeSetupCode( + """{"url":"ws://gateway.local:18789","bootstrapToken":"bootstrap-1"}""", + ) + + val resolved = + resolveGatewayConnectConfig( + useSetupCode = true, + setupCode = setupCode, + manualHostInput = "", + manualPortInput = "", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals("gateway.local", resolved?.host) + assertEquals(18789, resolved?.port) + assertEquals(false, resolved?.tls) + } + + @Test + fun resolveGatewayConnectPlanPreservesRuntimeOwnedAuthForUnchangedEndpoint() { + val plan = + resolveGatewayConnectPlan( useSetupCode = false, setupCode = "", savedManualHost = "127.0.0.1", @@ -525,20 +491,21 @@ class GatewayConfigResolverTest { manualHostInput = "127.0.0.1", manualPortInput = "18789", manualTlsInput = false, - fallbackBootstrapToken = "bootstrap-1", - fallbackToken = "", - fallbackPassword = "password-1", + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", ) - assertEquals("", resolved?.bootstrapToken) - assertEquals("", resolved?.token) - assertEquals("password-1", resolved?.password) + assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + assertEquals("", plan?.config?.password) } @Test - fun resolveGatewayConnectConfigManualDropsBootstrapTokenWhenEndpointChanges() { - val resolved = - resolveGatewayConnectConfig( + fun resolveGatewayConnectPlanReplacesAuthWhenEndpointChanges() { + val plan = + resolveGatewayConnectPlan( useSetupCode = false, setupCode = "", savedManualHost = "127.0.0.1", @@ -547,13 +514,123 @@ class GatewayConfigResolverTest { manualHostInput = "127.0.0.2", manualPortInput = "18789", manualTlsInput = false, - fallbackBootstrapToken = "bootstrap-1", - fallbackToken = "", - fallbackPassword = "", + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", ) - assertEquals("", resolved?.bootstrapToken) - assertEquals("127.0.0.2", resolved?.host) + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + assertEquals("127.0.0.2", plan?.config?.host) + } + + @Test + fun resolveGatewayConnectPlanTreatsMissingSavedEndpointAsReplacement() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "", + savedManualPort = "", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + } + + @Test + fun resolveGatewayConnectPlanMarksSetupCodeAsExplicitReplacement() { + val setupCode = + encodeSetupCode( + """{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""", + ) + + val plan = + resolveGatewayConnectPlan( + useSetupCode = true, + setupCode = setupCode, + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("bootstrap-1", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + } + + @Test + fun resolveGatewayConnectPlanUsesOneExplicitCredentialFamily() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "127.0.0.1", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "bootstrap", + tokenInput = "token", + passwordInput = "password", + ) + + assertEquals("token", plan?.config?.token) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.password) + } + + @Test + fun resolveGatewayConnectPlanReplacesStalePairingForExplicitBootstrapAuth() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "gateway.local", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "gateway.local", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "replacement-bootstrap", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("replacement-bootstrap", plan?.config?.bootstrapToken) + } + + @Test + fun resolveGatewayConnectPlanPreservesAuthForHostnameCaseOnlyEdit() { + val plan = + resolveGatewayConnectPlan( + useSetupCode = false, + setupCode = "", + savedManualHost = "Gateway.Local", + savedManualPort = "18789", + savedManualTls = false, + manualHostInput = "gateway.local", + manualPortInput = "18789", + manualTlsInput = false, + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", + ) + + assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) } @Test @@ -562,15 +639,12 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "", - savedManualPort = "", - savedManualTls = false, manualHostInput = "192.168.31.100", manualPortInput = "18789", manualTlsInput = false, - fallbackBootstrapToken = "bootstrap-1", - fallbackToken = "", - fallbackPassword = "", + bootstrapTokenInput = "bootstrap-1", + tokenInput = "", + passwordInput = "", ) assertEquals("192.168.31.100", resolved?.host) @@ -584,15 +658,12 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "", - savedManualPort = "", - savedManualTls = false, manualHostInput = "gateway.local", manualPortInput = "18789", manualTlsInput = false, - fallbackBootstrapToken = "bootstrap-1", - fallbackToken = "", - fallbackPassword = "", + bootstrapTokenInput = "bootstrap-1", + tokenInput = "", + passwordInput = "", ) assertEquals("gateway.local", resolved?.host) @@ -629,15 +700,12 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "", - savedManualPort = "", - savedManualTls = false, manualHostInput = "ws://192.168.178.57:18790", manualPortInput = "18789", manualTlsInput = true, - fallbackBootstrapToken = "", - fallbackToken = "", - fallbackPassword = "", + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", ) assertEquals("192.168.178.57", resolved?.host) @@ -698,15 +766,12 @@ class GatewayConfigResolverTest { resolveGatewayConnectConfig( useSetupCode = false, setupCode = "", - savedManualHost = "", - savedManualPort = "", - savedManualTls = true, manualHostInput = "mydevice.tail1234.ts.net", manualPortInput = "", manualTlsInput = true, - fallbackBootstrapToken = "", - fallbackToken = "", - fallbackPassword = "", + bootstrapTokenInput = "", + tokenInput = "", + passwordInput = "", ) assertEquals("mydevice.tail1234.ts.net", resolved?.host) 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 be3aa1681ea..6b678fe811e 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 @@ -498,9 +498,12 @@ class OnboardingFlowLogicTest { encodeSetupCode("""{"url":"ws://10.0.2.2:18789","bootstrapToken":"bootstrap-1"}""") val scanned = resolveScannedSetupCodeResult(setupCode) - val resolved = - resolveOnboardingGatewayConnectConfig( + val plan = + resolveOnboardingGatewayConnectPlan( setupCode = requireNotNull(scanned.setupCode), + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, manualHost = "127.0.0.1", manualPort = "18789", manualTls = false, @@ -508,20 +511,24 @@ class OnboardingFlowLogicTest { password = "stale-shared-password", ) - assertEquals("10.0.2.2", resolved?.host) - assertEquals(18789, resolved?.port) - assertEquals(false, resolved?.tls) - assertEquals("bootstrap-1", resolved?.bootstrapToken) - assertEquals("", resolved?.token) - assertEquals("", resolved?.password) + assertEquals(GatewaySavedAuthAction.REPLACE_SETUP, plan?.savedAuthAction) + assertEquals("10.0.2.2", plan?.config?.host) + assertEquals(18789, plan?.config?.port) + assertEquals(false, plan?.config?.tls) + assertEquals("bootstrap-1", plan?.config?.bootstrapToken) + assertEquals("", plan?.config?.token) + assertEquals("", plan?.config?.password) assertNull(scanned.error) } @Test fun resolvesOnboardingManualConnectConfigWhenSetupCodeIsBlank() { - val resolved = - resolveOnboardingGatewayConnectConfig( + val plan = + resolveOnboardingGatewayConnectPlan( setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, manualHost = "127.0.0.1", manualPort = "18789", manualTls = false, @@ -529,12 +536,33 @@ class OnboardingFlowLogicTest { password = "shared-password", ) - assertEquals("127.0.0.1", resolved?.host) - assertEquals(18789, resolved?.port) - assertEquals(false, resolved?.tls) - assertEquals("", resolved?.bootstrapToken) - assertEquals("shared-token", resolved?.token) - assertEquals("shared-password", resolved?.password) + assertEquals(GatewaySavedAuthAction.PRESERVE, plan?.savedAuthAction) + assertEquals("127.0.0.1", plan?.config?.host) + assertEquals(18789, plan?.config?.port) + assertEquals(false, plan?.config?.tls) + assertEquals("", plan?.config?.bootstrapToken) + assertEquals("shared-token", plan?.config?.token) + assertEquals("", plan?.config?.password) + } + + @Test + fun onboardingManualEndpointChangeReplacesSavedGatewayAuth() { + val plan = + resolveOnboardingGatewayConnectPlan( + setupCode = "", + savedManualHost = "127.0.0.1", + savedManualPort = "18789", + savedManualTls = false, + manualHost = "10.0.2.2", + manualPort = "18790", + manualTls = false, + token = "replacement-token", + password = "", + ) + + assertEquals(GatewaySavedAuthAction.REPLACE_ENDPOINT, plan?.savedAuthAction) + assertEquals("10.0.2.2", plan?.config?.host) + assertEquals("replacement-token", plan?.config?.token) } private fun encodeSetupCode(payloadJson: String): String = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadJson.toByteArray(Charsets.UTF_8)) diff --git a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt index 61b0c779c1d..ec4dc3873b2 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/ui/SettingsScreensTest.kt @@ -49,6 +49,24 @@ class SettingsScreensTest { assertEquals("Cannot reach gateway", gatewayStatusLabel("Connection failed", isConnected = false, gatewayConnectionProblem = problem)) } + @Test + fun gatewaySetupResetCopyExplainsCredentialAndApprovalImpact() { + val text = gatewaySettingsSetupResetConfirmationText() + + assertEquals(true, text.contains("saved setup credentials")) + assertEquals(true, text.contains("device tokens")) + assertEquals(true, text.contains("node capability approval")) + } + + @Test + fun devicePairingAdminCopySeparatesPairingFromNodeApproval() { + val text = devicePairingAdminUnavailableText() + + assertEquals(true, text.contains("approve new phone pairing")) + assertEquals(true, text.contains("Node capability approval is separate")) + assertEquals(true, text.contains("nodes approve ")) + } + private fun authProblem(code: String): GatewayConnectionProblem = GatewayConnectionProblem( code = code,