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 7bb92ba02e27..09113d428364 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 @@ -4,6 +4,8 @@ import ai.openclaw.app.chat.ChatMessage import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.OutgoingAttachment +import ai.openclaw.app.gateway.DeviceAuthStore +import ai.openclaw.app.gateway.DeviceIdentityStore import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary import ai.openclaw.app.node.CameraCaptureManager @@ -280,9 +282,51 @@ class MainViewModel( prefs.setGatewayPassword(value) } - /** Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */ - fun resetGatewaySetupAuth() { - ensureRuntime().resetGatewaySetupAuth() + /** Clears setup credentials without starting the runtime just to discard first-run pairing auth. */ + private fun resetGatewaySetupAuth() { + runtimeRef.value?.resetGatewaySetupAuth() ?: resetGatewaySetupAuthWithoutRuntime() + } + + private fun resetGatewaySetupAuthWithoutRuntime() { + prefs.clearGatewaySetupAuth() + val deviceId = DeviceIdentityStore(nodeApp).loadOrCreate().deviceId + val deviceAuthStore = DeviceAuthStore(prefs) + deviceAuthStore.clearToken(deviceId, "node") + deviceAuthStore.clearToken(deviceId, "operator") + } + + fun saveGatewayConfigAndConnect( + host: String, + port: Int, + tls: Boolean, + token: String, + bootstrapToken: String, + password: String, + resetSetupAuth: Boolean, + ) { + // 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) { + 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), + NodeRuntime.GatewayConnectAuth( + token = token.ifEmpty { null }, + bootstrapToken = bootstrapToken.ifEmpty { null }, + password = password.ifEmpty { null }, + ), + ) + } } /** Marks onboarding complete and starts the runtime before UI observes connected-state flows. */ @@ -295,10 +339,12 @@ class MainViewModel( /** Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */ fun pairNewGateway() { - runtimeRef.value?.disconnect() - resetGatewaySetupAuth() - _startOnboardingAtGatewaySetup.value = true - prefs.setOnboardingCompleted(false) + viewModelScope.launch(Dispatchers.Default) { + runtimeRef.value?.disconnect() + resetGatewaySetupAuth() + prefs.setOnboardingCompleted(false) + _startOnboardingAtGatewaySetup.value = true + } } /** Acknowledges the one-shot request that opens onboarding at the gateway setup step. */ @@ -394,13 +440,21 @@ class MainViewModel( } fun refreshGatewayConnection() { - ensureRuntime().refreshGatewayConnection() + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().refreshGatewayConnection() + } } fun connect(endpoint: GatewayEndpoint) { ensureRuntime().connect(endpoint) } + fun connectInBackground(endpoint: GatewayEndpoint) { + viewModelScope.launch(Dispatchers.Default) { + ensureRuntime().connect(endpoint) + } + } + fun connect( endpoint: GatewayEndpoint, token: String?, 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 880c3a40e09e..16e327123681 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 @@ -1,7 +1,6 @@ package ai.openclaw.app.ui import ai.openclaw.app.MainViewModel -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.ui.mobileCardSurface import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke @@ -291,27 +290,14 @@ fun ConnectTabScreen(viewModel: MainViewModel) { } validationText = null - if (inputMode == ConnectInputMode.SetupCode) { - // Setup-code auth should replace old bootstrap/shared credentials; - // manual reconnects keep existing typed credentials. - viewModel.resetGatewaySetupAuth() - } - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - if (config.token.isNotBlank()) { - viewModel.setGatewayToken(config.token) - } else if (config.bootstrapToken.isNotBlank()) { - viewModel.setGatewayToken("") - } - viewModel.setGatewayPassword(config.password) - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { 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, ) }, modifier = Modifier.fillMaxWidth().height(52.dp), 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 7c498a1cff34..33d776c81a2e 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 @@ -3,7 +3,6 @@ package ai.openclaw.app.ui import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.SensitiveFeatureConfig -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.ui.design.ClawDesignTheme import ai.openclaw.app.ui.design.ClawErrorState @@ -254,7 +253,7 @@ fun OnboardingFlow( onUseNearby = { val endpoint = gateways.firstOrNull() ?: return@GatewaySetupScreen attemptedConnect = true - viewModel.connect(endpoint) + viewModel.connectInBackground(endpoint) step = OnboardingStep.Recovery }, onPair = { @@ -275,21 +274,14 @@ fun OnboardingFlow( setupError = null attemptedConnect = true connectAttemptStartedAtMs = SystemClock.elapsedRealtime() - // Setup-code pairing replaces any stale shared credentials before - // the bootstrap token is stored for the first authenticated connect. - viewModel.resetGatewaySetupAuth() - viewModel.setManualEnabled(true) - viewModel.setManualHost(config.host) - viewModel.setManualPort(config.port) - viewModel.setManualTls(config.tls) - viewModel.setGatewayBootstrapToken(config.bootstrapToken) - viewModel.setGatewayToken(config.token) - viewModel.setGatewayPassword(config.password) - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = config.host, + port = config.port, + tls = config.tls, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + resetSetupAuth = true, ) step = OnboardingStep.Recovery }, @@ -317,11 +309,14 @@ fun OnboardingFlow( token = token, password = password, ) ?: return@GatewayRecoveryScreen - viewModel.connect( - GatewayEndpoint.manual(host = config.host, port = config.port), - token = config.token.ifEmpty { null }, - bootstrapToken = config.bootstrapToken.ifEmpty { null }, - password = config.password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = config.host, + port = config.port, + tls = config.tls, + token = config.token, + bootstrapToken = config.bootstrapToken, + password = config.password, + resetSetupAuth = false, ) }, onEdit = { step = OnboardingStep.Gateway }, 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 aaedee7c6804..7978981f5ecc 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 @@ -8,7 +8,6 @@ import ai.openclaw.app.LocationMode import ai.openclaw.app.MainViewModel import ai.openclaw.app.NotificationPackageFilterMode import ai.openclaw.app.chat.ChatPendingToolCall -import ai.openclaw.app.gateway.GatewayEndpoint import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.ui.design.ClawDetailRow import ai.openclaw.app.ui.design.ClawIconBadge @@ -897,18 +896,14 @@ private fun GatewaySettingsScreen( .orEmpty() .ifEmpty { passwordInput.trim() } validationText = null - viewModel.setManualEnabled(true) - viewModel.setManualHost(endpointConfig.host) - viewModel.setManualPort(endpointConfig.port) - viewModel.setManualTls(endpointConfig.tls) - viewModel.setGatewayBootstrapToken(bootstrapToken) - viewModel.setGatewayToken(token) - viewModel.setGatewayPassword(password) - viewModel.connect( - GatewayEndpoint.manual(host = endpointConfig.host, port = endpointConfig.port), - token = token.ifEmpty { null }, - bootstrapToken = bootstrapToken.ifEmpty { null }, - password = password.ifEmpty { null }, + viewModel.saveGatewayConfigAndConnect( + host = endpointConfig.host, + port = endpointConfig.port, + tls = endpointConfig.tls, + token = token, + bootstrapToken = bootstrapToken, + password = password, + resetSetupAuth = setup != null, ) }, modifier = Modifier.fillMaxWidth(),