From 64e412e57e72dac022bf3732788ce48c40db634d Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 16 Mar 2026 18:53:55 +0530 Subject: [PATCH] fix(android): lazy-init node runtime after onboarding --- .../main/java/ai/openclaw/app/MainActivity.kt | 22 +- .../java/ai/openclaw/app/MainViewModel.kt | 237 +++++++++++------- .../src/main/java/ai/openclaw/app/NodeApp.kt | 13 +- .../ai/openclaw/app/NodeForegroundService.kt | 8 +- .../main/java/ai/openclaw/app/NodeRuntime.kt | 7 +- 5 files changed, 186 insertions(+), 101 deletions(-) diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt index 40cabebd17c..d9ad83175b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt @@ -18,14 +18,13 @@ import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester + private var didAttachRuntimeUi = false + private var didStartNodeService = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) - viewModel.camera.attachLifecycleOwner(this) - viewModel.camera.attachPermissionRequester(permissionRequester) - viewModel.sms.attachPermissionRequester(permissionRequester) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -39,6 +38,20 @@ class MainActivity : ComponentActivity() { } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.runtimeInitialized.collect { ready -> + if (!ready || didAttachRuntimeUi) return@collect + viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + didAttachRuntimeUi = true + if (!didStartNodeService) { + NodeForegroundService.start(this@MainActivity) + didStartNodeService = true + } + } + } + } + setContent { OpenClawTheme { Surface(modifier = Modifier) { @@ -46,9 +59,6 @@ class MainActivity : ComponentActivity() { } } } - - // Keep startup path lean: start foreground service after first frame. - window.decorView.post { NodeForegroundService.start(this) } } override fun onStart() { 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 80f42e02843..82fe643314c 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 @@ -2,209 +2,268 @@ package ai.openclaw.app import android.app.Application import androidx.lifecycle.AndroidViewModel -import ai.openclaw.app.gateway.GatewayEndpoint +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +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.GatewayEndpoint import ai.openclaw.app.node.CameraCaptureManager import ai.openclaw.app.node.CanvasController import ai.openclaw.app.node.SmsManager import ai.openclaw.app.voice.VoiceConversationEntry +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +@OptIn(ExperimentalCoroutinesApi::class) class MainViewModel(app: Application) : AndroidViewModel(app) { - private val runtime: NodeRuntime = (app as NodeApp).runtime + private val nodeApp = app as NodeApp + private val prefs = nodeApp.prefs + private val runtimeRef = MutableStateFlow(null) + private var foreground = true - val canvas: CanvasController = runtime.canvas - val canvasCurrentUrl: StateFlow = runtime.canvas.currentUrl - val canvasA2uiHydrated: StateFlow = runtime.canvasA2uiHydrated - val canvasRehydratePending: StateFlow = runtime.canvasRehydratePending - val canvasRehydrateErrorText: StateFlow = runtime.canvasRehydrateErrorText - val camera: CameraCaptureManager = runtime.camera - val sms: SmsManager = runtime.sms + private fun ensureRuntime(): NodeRuntime { + runtimeRef.value?.let { return it } + val runtime = nodeApp.ensureRuntime() + runtime.setForeground(foreground) + runtimeRef.value = runtime + return runtime + } - val gateways: StateFlow> = runtime.gateways - val discoveryStatusText: StateFlow = runtime.discoveryStatusText + private fun runtimeState( + initial: T, + selector: (NodeRuntime) -> StateFlow, + ): StateFlow = + runtimeRef + .flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) } + .stateIn(viewModelScope, SharingStarted.Eagerly, initial) - val isConnected: StateFlow = runtime.isConnected - val isNodeConnected: StateFlow = runtime.nodeConnected - val statusText: StateFlow = runtime.statusText - val serverName: StateFlow = runtime.serverName - val remoteAddress: StateFlow = runtime.remoteAddress - val pendingGatewayTrust: StateFlow = runtime.pendingGatewayTrust - val isForeground: StateFlow = runtime.isForeground - val seamColorArgb: StateFlow = runtime.seamColorArgb - val mainSessionKey: StateFlow = runtime.mainSessionKey + val runtimeInitialized: StateFlow = + runtimeRef + .flatMapLatest { runtime -> flowOf(runtime != null) } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - val cameraHud: StateFlow = runtime.cameraHud - val cameraFlashToken: StateFlow = runtime.cameraFlashToken + val canvasCurrentUrl: StateFlow = runtimeState(initial = null) { it.canvas.currentUrl } + val canvasA2uiHydrated: StateFlow = runtimeState(initial = false) { it.canvasA2uiHydrated } + val canvasRehydratePending: StateFlow = runtimeState(initial = false) { it.canvasRehydratePending } + val canvasRehydrateErrorText: StateFlow = runtimeState(initial = null) { it.canvasRehydrateErrorText } - val instanceId: StateFlow = runtime.instanceId - val displayName: StateFlow = runtime.displayName - val cameraEnabled: StateFlow = runtime.cameraEnabled - val locationMode: StateFlow = runtime.locationMode - val locationPreciseEnabled: StateFlow = runtime.locationPreciseEnabled - val preventSleep: StateFlow = runtime.preventSleep - val micEnabled: StateFlow = runtime.micEnabled - val micCooldown: StateFlow = runtime.micCooldown - val micStatusText: StateFlow = runtime.micStatusText - val micLiveTranscript: StateFlow = runtime.micLiveTranscript - val micIsListening: StateFlow = runtime.micIsListening - val micQueuedMessages: StateFlow> = runtime.micQueuedMessages - val micConversation: StateFlow> = runtime.micConversation - val micInputLevel: StateFlow = runtime.micInputLevel - val micIsSending: StateFlow = runtime.micIsSending - val speakerEnabled: StateFlow = runtime.speakerEnabled - val manualEnabled: StateFlow = runtime.manualEnabled - val manualHost: StateFlow = runtime.manualHost - val manualPort: StateFlow = runtime.manualPort - val manualTls: StateFlow = runtime.manualTls - val gatewayToken: StateFlow = runtime.gatewayToken - val onboardingCompleted: StateFlow = runtime.onboardingCompleted - val canvasDebugStatusEnabled: StateFlow = runtime.canvasDebugStatusEnabled + val gateways: StateFlow> = runtimeState(initial = emptyList()) { it.gateways } + val discoveryStatusText: StateFlow = runtimeState(initial = "Searching…") { it.discoveryStatusText } - val chatSessionKey: StateFlow = runtime.chatSessionKey - val chatSessionId: StateFlow = runtime.chatSessionId - val chatMessages = runtime.chatMessages - val chatError: StateFlow = runtime.chatError - val chatHealthOk: StateFlow = runtime.chatHealthOk - val chatThinkingLevel: StateFlow = runtime.chatThinkingLevel - val chatStreamingAssistantText: StateFlow = runtime.chatStreamingAssistantText - val chatPendingToolCalls = runtime.chatPendingToolCalls - val chatSessions = runtime.chatSessions - val pendingRunCount: StateFlow = runtime.pendingRunCount + val isConnected: StateFlow = runtimeState(initial = false) { it.isConnected } + val isNodeConnected: StateFlow = runtimeState(initial = false) { it.nodeConnected } + val statusText: StateFlow = runtimeState(initial = "Offline") { it.statusText } + val serverName: StateFlow = runtimeState(initial = null) { it.serverName } + val remoteAddress: StateFlow = runtimeState(initial = null) { it.remoteAddress } + val pendingGatewayTrust: StateFlow = runtimeState(initial = null) { it.pendingGatewayTrust } + val seamColorArgb: StateFlow = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb } + val mainSessionKey: StateFlow = runtimeState(initial = "main") { it.mainSessionKey } + + val cameraHud: StateFlow = runtimeState(initial = null) { it.cameraHud } + val cameraFlashToken: StateFlow = runtimeState(initial = 0L) { it.cameraFlashToken } + + val instanceId: StateFlow = prefs.instanceId + val displayName: StateFlow = prefs.displayName + val cameraEnabled: StateFlow = prefs.cameraEnabled + val locationMode: StateFlow = prefs.locationMode + val locationPreciseEnabled: StateFlow = prefs.locationPreciseEnabled + val preventSleep: StateFlow = prefs.preventSleep + val manualEnabled: StateFlow = prefs.manualEnabled + val manualHost: StateFlow = prefs.manualHost + val manualPort: StateFlow = prefs.manualPort + val manualTls: StateFlow = prefs.manualTls + val gatewayToken: StateFlow = prefs.gatewayToken + val onboardingCompleted: StateFlow = prefs.onboardingCompleted + val canvasDebugStatusEnabled: StateFlow = prefs.canvasDebugStatusEnabled + val speakerEnabled: StateFlow = prefs.speakerEnabled + val micEnabled: StateFlow = prefs.talkEnabled + + val micCooldown: StateFlow = runtimeState(initial = false) { it.micCooldown } + val micStatusText: StateFlow = runtimeState(initial = "Mic off") { it.micStatusText } + val micLiveTranscript: StateFlow = runtimeState(initial = null) { it.micLiveTranscript } + val micIsListening: StateFlow = runtimeState(initial = false) { it.micIsListening } + val micQueuedMessages: StateFlow> = runtimeState(initial = emptyList()) { it.micQueuedMessages } + val micConversation: StateFlow> = runtimeState(initial = emptyList()) { it.micConversation } + val micInputLevel: StateFlow = runtimeState(initial = 0f) { it.micInputLevel } + val micIsSending: StateFlow = runtimeState(initial = false) { it.micIsSending } + + val chatSessionKey: StateFlow = runtimeState(initial = "main") { it.chatSessionKey } + val chatSessionId: StateFlow = runtimeState(initial = null) { it.chatSessionId } + val chatMessages: StateFlow> = runtimeState(initial = emptyList()) { it.chatMessages } + val chatError: StateFlow = runtimeState(initial = null) { it.chatError } + val chatHealthOk: StateFlow = runtimeState(initial = false) { it.chatHealthOk } + val chatThinkingLevel: StateFlow = runtimeState(initial = "off") { it.chatThinkingLevel } + val chatStreamingAssistantText: StateFlow = runtimeState(initial = null) { it.chatStreamingAssistantText } + val chatPendingToolCalls: StateFlow> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls } + val chatSessions: StateFlow> = runtimeState(initial = emptyList()) { it.chatSessions } + val pendingRunCount: StateFlow = runtimeState(initial = 0) { it.pendingRunCount } + + init { + if (prefs.onboardingCompleted.value) { + ensureRuntime() + } + } + + val canvas: CanvasController + get() = ensureRuntime().canvas + + val camera: CameraCaptureManager + get() = ensureRuntime().camera + + val sms: SmsManager + get() = ensureRuntime().sms + + fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) { + val runtime = runtimeRef.value ?: return + runtime.camera.attachLifecycleOwner(owner) + runtime.camera.attachPermissionRequester(permissionRequester) + runtime.sms.attachPermissionRequester(permissionRequester) + } fun setForeground(value: Boolean) { - runtime.setForeground(value) + foreground = value + runtimeRef.value?.setForeground(value) } fun setDisplayName(value: String) { - runtime.setDisplayName(value) + prefs.setDisplayName(value) } fun setCameraEnabled(value: Boolean) { - runtime.setCameraEnabled(value) + prefs.setCameraEnabled(value) } fun setLocationMode(mode: LocationMode) { - runtime.setLocationMode(mode) + prefs.setLocationMode(mode) } fun setLocationPreciseEnabled(value: Boolean) { - runtime.setLocationPreciseEnabled(value) + prefs.setLocationPreciseEnabled(value) } fun setPreventSleep(value: Boolean) { - runtime.setPreventSleep(value) + prefs.setPreventSleep(value) } fun setManualEnabled(value: Boolean) { - runtime.setManualEnabled(value) + prefs.setManualEnabled(value) } fun setManualHost(value: String) { - runtime.setManualHost(value) + prefs.setManualHost(value) } fun setManualPort(value: Int) { - runtime.setManualPort(value) + prefs.setManualPort(value) } fun setManualTls(value: Boolean) { - runtime.setManualTls(value) + prefs.setManualTls(value) } fun setGatewayToken(value: String) { - runtime.setGatewayToken(value) + prefs.setGatewayToken(value) } fun setGatewayBootstrapToken(value: String) { - runtime.setGatewayBootstrapToken(value) + prefs.setGatewayBootstrapToken(value) } fun setGatewayPassword(value: String) { - runtime.setGatewayPassword(value) + prefs.setGatewayPassword(value) } fun setOnboardingCompleted(value: Boolean) { - runtime.setOnboardingCompleted(value) + if (value) { + ensureRuntime() + } + prefs.setOnboardingCompleted(value) } fun setCanvasDebugStatusEnabled(value: Boolean) { - runtime.setCanvasDebugStatusEnabled(value) + prefs.setCanvasDebugStatusEnabled(value) } fun setVoiceScreenActive(active: Boolean) { - runtime.setVoiceScreenActive(active) + ensureRuntime().setVoiceScreenActive(active) } fun setMicEnabled(enabled: Boolean) { - runtime.setMicEnabled(enabled) + ensureRuntime().setMicEnabled(enabled) } fun setSpeakerEnabled(enabled: Boolean) { - runtime.setSpeakerEnabled(enabled) + ensureRuntime().setSpeakerEnabled(enabled) } fun refreshGatewayConnection() { - runtime.refreshGatewayConnection() + ensureRuntime().refreshGatewayConnection() } fun connect(endpoint: GatewayEndpoint) { - runtime.connect(endpoint) + ensureRuntime().connect(endpoint) } fun connectManual() { - runtime.connectManual() + ensureRuntime().connectManual() } fun disconnect() { - runtime.disconnect() + runtimeRef.value?.disconnect() } fun acceptGatewayTrustPrompt() { - runtime.acceptGatewayTrustPrompt() + runtimeRef.value?.acceptGatewayTrustPrompt() } fun declineGatewayTrustPrompt() { - runtime.declineGatewayTrustPrompt() + runtimeRef.value?.declineGatewayTrustPrompt() } fun handleCanvasA2UIActionFromWebView(payloadJson: String) { - runtime.handleCanvasA2UIActionFromWebView(payloadJson) + ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson) } fun requestCanvasRehydrate(source: String = "screen_tab") { - runtime.requestCanvasRehydrate(source = source, force = true) + ensureRuntime().requestCanvasRehydrate(source = source, force = true) } fun refreshHomeCanvasOverviewIfConnected() { - runtime.refreshHomeCanvasOverviewIfConnected() + ensureRuntime().refreshHomeCanvasOverviewIfConnected() } fun loadChat(sessionKey: String) { - runtime.loadChat(sessionKey) + ensureRuntime().loadChat(sessionKey) } fun refreshChat() { - runtime.refreshChat() + ensureRuntime().refreshChat() } fun refreshChatSessions(limit: Int? = null) { - runtime.refreshChatSessions(limit = limit) + ensureRuntime().refreshChatSessions(limit = limit) } fun setChatThinkingLevel(level: String) { - runtime.setChatThinkingLevel(level) + ensureRuntime().setChatThinkingLevel(level) } fun switchChatSession(sessionKey: String) { - runtime.switchChatSession(sessionKey) + ensureRuntime().switchChatSession(sessionKey) } fun abortChat() { - runtime.abortChat() + ensureRuntime().abortChat() } fun sendChat(message: String, thinking: String, attachments: List) { - runtime.sendChat(message = message, thinking = thinking, attachments = attachments) + ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt index 0d172a8abe7..adfd4b73907 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeApp.kt @@ -4,7 +4,18 @@ import android.app.Application import android.os.StrictMode class NodeApp : Application() { - val runtime: NodeRuntime by lazy { NodeRuntime(this) } + val prefs: SecurePrefs by lazy { SecurePrefs(this) } + + @Volatile private var runtimeInstance: NodeRuntime? = null + + fun ensureRuntime(): NodeRuntime { + runtimeInstance?.let { return it } + return synchronized(this) { + runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it } + } + } + + fun peekRuntime(): NodeRuntime? = runtimeInstance override fun onCreate() { super.onCreate() diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt index 5761567ebcc..4c7ccdd56e5 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeForegroundService.kt @@ -28,7 +28,11 @@ class NodeForegroundService : Service() { val initial = buildNotification(title = "OpenClaw Node", text = "Starting…") startForegroundWithTypes(notification = initial) - val runtime = (application as NodeApp).runtime + val runtime = (application as NodeApp).peekRuntime() + if (runtime == null) { + stopSelf() + return + } notificationJob = scope.launch { combine( @@ -59,7 +63,7 @@ class NodeForegroundService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { ACTION_STOP -> { - (application as NodeApp).runtime.disconnect() + (application as NodeApp).peekRuntime()?.disconnect() stopSelf() return START_NOT_STICKY } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index c2bce9a247a..9ee6198e15c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject import java.util.UUID import java.util.concurrent.atomic.AtomicLong -class NodeRuntime(context: Context) { +class NodeRuntime( + context: Context, + val prefs: SecurePrefs = SecurePrefs(context.applicationContext), +) { private val appContext = context.applicationContext private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - - val prefs = SecurePrefs(appContext) private val deviceAuthStore = DeviceAuthStore(prefs) val canvas = CanvasController() val camera = CameraCaptureManager(appContext)