@@ -4,6 +4,8 @@ import ai.openclaw.app.chat.ChatMessage
44import ai.openclaw.app.chat.ChatPendingToolCall
55import ai.openclaw.app.chat.ChatSessionEntry
66import ai.openclaw.app.chat.OutgoingAttachment
7+ import ai.openclaw.app.gateway.DeviceAuthStore
8+ import ai.openclaw.app.gateway.DeviceIdentityStore
79import ai.openclaw.app.gateway.GatewayEndpoint
810import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
911import ai.openclaw.app.node.CameraCaptureManager
@@ -14,13 +16,15 @@ import android.app.Application
1416import androidx.lifecycle.AndroidViewModel
1517import androidx.lifecycle.LifecycleOwner
1618import androidx.lifecycle.viewModelScope
19+ import kotlinx.coroutines.Dispatchers
1720import kotlinx.coroutines.ExperimentalCoroutinesApi
1821import kotlinx.coroutines.flow.MutableStateFlow
1922import kotlinx.coroutines.flow.SharingStarted
2023import kotlinx.coroutines.flow.StateFlow
2124import kotlinx.coroutines.flow.flatMapLatest
2225import kotlinx.coroutines.flow.flowOf
2326import kotlinx.coroutines.flow.stateIn
27+ import kotlinx.coroutines.launch
2428
2529/* *
2630 * UI-facing bridge that exposes NodeRuntime and preference state as Compose-friendly StateFlows.
@@ -32,7 +36,11 @@ class MainViewModel(
3236 private val nodeApp = app as NodeApp
3337 private val prefs = nodeApp.prefs
3438 private val runtimeRef = MutableStateFlow <NodeRuntime ?>(null )
35- private var foreground = true
39+
40+ @Volatile private var foreground = false
41+
42+ @Volatile private var runtimeStartupQueued = false
43+
3644 private val _requestedHomeDestination = MutableStateFlow <HomeDestination ?>(null )
3745 val requestedHomeDestination: StateFlow <HomeDestination ?> = _requestedHomeDestination
3846 private val _startOnboardingAtGatewaySetup = MutableStateFlow (false )
@@ -53,6 +61,19 @@ class MainViewModel(
5361 return runtime
5462 }
5563
64+ /* *
65+ * Starts the node runtime off the main thread so fresh installs can render
66+ * the shell before encrypted prefs, device identity, and gateway setup warm up.
67+ */
68+ private fun queueRuntimeStartup () {
69+ if (runtimeRef.value != null || runtimeStartupQueued) return
70+ runtimeStartupQueued = true
71+ viewModelScope.launch(Dispatchers .Default ) {
72+ runCatching { ensureRuntime() }
73+ runtimeStartupQueued = false
74+ }
75+ }
76+
5677 /* *
5778 * Adapts a runtime StateFlow to a stable ViewModel StateFlow before runtime startup.
5879 */
@@ -91,6 +112,7 @@ class MainViewModel(
91112 val isConnected: StateFlow <Boolean > = runtimeState(initial = false ) { it.isConnected }
92113 val isNodeConnected: StateFlow <Boolean > = runtimeState(initial = false ) { it.nodeConnected }
93114 val statusText: StateFlow <String > = runtimeState(initial = " Offline" ) { it.statusText }
115+ val gatewayConnectionProblem: StateFlow <GatewayConnectionProblem ?> = runtimeState(initial = null ) { it.gatewayConnectionProblem }
94116 val serverName: StateFlow <String ?> = runtimeState(initial = null ) { it.serverName }
95117 val remoteAddress: StateFlow <String ?> = runtimeState(initial = null ) { it.remoteAddress }
96118 val gatewayVersion: StateFlow <String ?> = runtimeState(initial = null ) { it.gatewayVersion }
@@ -180,12 +202,6 @@ class MainViewModel(
180202 val chatSessions: StateFlow <List <ChatSessionEntry >> = runtimeState(initial = emptyList()) { it.chatSessions }
181203 val pendingRunCount: StateFlow <Int > = runtimeState(initial = 0 ) { it.pendingRunCount }
182204
183- init {
184- if (prefs.onboardingCompleted.value) {
185- ensureRuntime()
186- }
187- }
188-
189205 val canvas: CanvasController
190206 get() = ensureRuntime().canvas
191207
@@ -213,13 +229,10 @@ class MainViewModel(
213229 */
214230 fun setForeground (value : Boolean ) {
215231 foreground = value
216- val runtime =
217- if (value && prefs.onboardingCompleted.value) {
218- ensureRuntime()
219- } else {
220- runtimeRef.value
221- }
222- runtime?.setForeground(value)
232+ if (value && prefs.onboardingCompleted.value) {
233+ queueRuntimeStartup()
234+ }
235+ runtimeRef.value?.setForeground(value)
223236 }
224237
225238 fun setDisplayName (value : String ) {
@@ -270,9 +283,51 @@ class MainViewModel(
270283 prefs.setGatewayPassword(value)
271284 }
272285
273- /* * Clears setup credentials through the runtime so active gateway sessions drop stale auth state. */
274- fun resetGatewaySetupAuth () {
275- ensureRuntime().resetGatewaySetupAuth()
286+ /* * Clears setup credentials without starting the runtime just to discard first-run pairing auth. */
287+ private fun resetGatewaySetupAuth () {
288+ runtimeRef.value?.resetGatewaySetupAuth() ? : resetGatewaySetupAuthWithoutRuntime()
289+ }
290+
291+ private fun resetGatewaySetupAuthWithoutRuntime () {
292+ prefs.clearGatewaySetupAuth()
293+ val deviceId = DeviceIdentityStore (nodeApp).loadOrCreate().deviceId
294+ val deviceAuthStore = DeviceAuthStore (prefs)
295+ deviceAuthStore.clearToken(deviceId, " node" )
296+ deviceAuthStore.clearToken(deviceId, " operator" )
297+ }
298+
299+ fun saveGatewayConfigAndConnect (
300+ host : String ,
301+ port : Int ,
302+ tls : Boolean ,
303+ token : String ,
304+ bootstrapToken : String ,
305+ password : String ,
306+ resetSetupAuth : Boolean ,
307+ ) {
308+ // Gateway pairing touches encrypted prefs, identity files, and sockets; keep
309+ // the whole sequence off the Compose thread so retries cannot trigger ANRs.
310+ viewModelScope.launch(Dispatchers .Default ) {
311+ if (resetSetupAuth) {
312+ resetGatewaySetupAuth()
313+ }
314+ prefs.setManualEnabled(true )
315+ prefs.setManualHost(host)
316+ prefs.setManualPort(port)
317+ prefs.setManualTls(tls)
318+ prefs.setGatewayBootstrapToken(bootstrapToken)
319+ prefs.setGatewayToken(token)
320+ prefs.setGatewayPassword(password)
321+ ensureRuntime()
322+ .connect(
323+ GatewayEndpoint .manual(host = host, port = port),
324+ NodeRuntime .GatewayConnectAuth (
325+ token = token.ifEmpty { null },
326+ bootstrapToken = bootstrapToken.ifEmpty { null },
327+ password = password.ifEmpty { null },
328+ ),
329+ )
330+ }
276331 }
277332
278333 /* * Marks onboarding complete and starts the runtime before UI observes connected-state flows. */
@@ -285,10 +340,12 @@ class MainViewModel(
285340
286341 /* * Re-enters gateway setup after disconnecting and clearing one-time setup credentials. */
287342 fun pairNewGateway () {
288- runtimeRef.value?.disconnect()
289- resetGatewaySetupAuth()
290- _startOnboardingAtGatewaySetup .value = true
291- prefs.setOnboardingCompleted(false )
343+ viewModelScope.launch(Dispatchers .Default ) {
344+ runtimeRef.value?.disconnect()
345+ resetGatewaySetupAuth()
346+ prefs.setOnboardingCompleted(false )
347+ _startOnboardingAtGatewaySetup .value = true
348+ }
292349 }
293350
294351 /* * Acknowledges the one-shot request that opens onboarding at the gateway setup step. */
@@ -384,13 +441,25 @@ class MainViewModel(
384441 }
385442
386443 fun refreshGatewayConnection () {
387- ensureRuntime().refreshGatewayConnection()
444+ viewModelScope.launch(Dispatchers .Default ) {
445+ ensureRuntime().refreshGatewayConnection()
446+ }
447+ }
448+
449+ fun startGatewayDiscovery () {
450+ queueRuntimeStartup()
388451 }
389452
390453 fun connect (endpoint : GatewayEndpoint ) {
391454 ensureRuntime().connect(endpoint)
392455 }
393456
457+ fun connectInBackground (endpoint : GatewayEndpoint ) {
458+ viewModelScope.launch(Dispatchers .Default ) {
459+ ensureRuntime().connect(endpoint)
460+ }
461+ }
462+
394463 fun connect (
395464 endpoint : GatewayEndpoint ,
396465 token : String? ,
0 commit comments