diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index ee6f57d5a50..5c26bdbb52f 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -131,7 +131,7 @@ }, { "kind": "ui-named-argument", - "line": 199, + "line": 247, "path": "apps/android/app/src/main/java/ai/openclaw/app/MainActivity.kt", "source": "OPENCLAW", "surface": "android", 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 1ec008c8fa2..fd2a1aca187 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 @@ -42,16 +42,15 @@ class MainActivity : ComponentActivity() { private val viewModel: MainViewModel by viewModels() private lateinit var permissionRequester: PermissionRequester private var initializedViewModel: MainViewModel? = null - private var didAttachRuntimeUi = false - private var didStartNodeService = false private var didStartViewModelCollectors = false private var foreground = false - private var pendingIntent: Intent? = null + private val pendingIntentRouter = MainActivityPendingIntentRouter() + private val runtimeUiStarter = MainActivityRuntimeUiStarter() private var screenshotScene: AndroidScreenshotScene? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pendingIntent = intent + pendingIntentRouter.setInitialIntent(intent) WindowCompat.setDecorFitsSystemWindows(window, false) permissionRequester = PermissionRequester(this) if (BuildConfig.DEBUG) { @@ -111,8 +110,9 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: android.content.Intent) { super.onNewIntent(intent) setIntent(intent) - pendingIntent = intent - initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = intent) } + pendingIntentRouter.onNewIntent(intent) { routedIntent -> + initializedViewModel?.let { handleAssistantIntent(viewModel = it, intent = routedIntent) } + } } /** @@ -123,9 +123,8 @@ class MainActivity : ComponentActivity() { initializedViewModel = readyViewModel readyViewModel.setForeground(foreground) startViewModelCollectors(readyViewModel) - pendingIntent?.let { initialIntent -> + pendingIntentRouter.activate { initialIntent -> handleAssistantIntent(viewModel = readyViewModel, intent = initialIntent) - pendingIntent = null } } @@ -151,18 +150,17 @@ class MainActivity : ComponentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { readyViewModel.runtimeInitialized.collect { ready -> - if (!ready || didAttachRuntimeUi) return@collect - if (screenshotScene != null) { - didAttachRuntimeUi = true - return@collect - } - // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. - readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) - didAttachRuntimeUi = true - if (!didStartNodeService) { - NodeForegroundService.start(this@MainActivity) - didStartNodeService = true - } + runtimeUiStarter.onRuntimeInitialized( + ready = ready, + startRuntimeUi = screenshotScene == null, + attachRuntimeUi = { + // Runtime UI helpers need an Activity owner, so attach once after NodeRuntime is ready. + readyViewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester) + }, + startNodeService = { + NodeForegroundService.start(this@MainActivity) + }, + ) } } } @@ -184,6 +182,56 @@ class MainActivity : ComponentActivity() { } } +/** Holds launch intents until ViewModel activation, then routes every later intent immediately. */ +internal class MainActivityPendingIntentRouter { + private var activated = false + private var pendingIntent: Intent? = null + + fun setInitialIntent(intent: Intent?) { + if (!activated) pendingIntent = intent + } + + fun onNewIntent( + intent: Intent, + routeIntent: (Intent) -> Unit, + ) { + if (activated) { + routeIntent(intent) + return + } + pendingIntent = intent + } + + fun activate(routeIntent: (Intent) -> Unit): Boolean { + if (activated) return false + activated = true + pendingIntent?.let(routeIntent) + pendingIntent = null + return true + } +} + +/** Preserves one-shot runtime UI startup while allowing screenshot fixtures to skip side effects. */ +internal class MainActivityRuntimeUiStarter { + private var completed = false + + fun onRuntimeInitialized( + ready: Boolean, + startRuntimeUi: Boolean, + attachRuntimeUi: () -> Unit, + startNodeService: () -> Unit, + ) { + if (!ready || completed) return + if (!startRuntimeUi) { + completed = true + return + } + attachRuntimeUi() + completed = true + startNodeService() + } +} + @Composable private fun StartupSurface() { Surface( diff --git a/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt new file mode 100644 index 00000000000..a7174ad3234 --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/MainActivityLifecycleTest.kt @@ -0,0 +1,95 @@ +package ai.openclaw.app + +import android.content.Intent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class MainActivityLifecycleTest { + @Test + fun pendingIntentRouterUsesLatestIntentBeforeActivation() { + val router = MainActivityPendingIntentRouter() + val initial = Intent("initial") + val replacement = Intent("replacement") + val routed = mutableListOf() + + router.setInitialIntent(initial) + router.onNewIntent(replacement, routed::add) + + assertTrue(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + assertFalse(router.activate(routed::add)) + assertEquals(listOf(replacement), routed) + } + + @Test + fun pendingIntentRouterRoutesImmediatelyAfterActivation() { + val router = MainActivityPendingIntentRouter() + val routed = mutableListOf() + val next = Intent("next") + + assertTrue(router.activate(routed::add)) + router.onNewIntent(next, routed::add) + router.setInitialIntent(Intent("ignored")) + + assertEquals(listOf(next), routed) + } + + @Test + fun runtimeUiStarterWaitsForReadinessAndStartsOnce() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = false, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(1, attachCount) + assertEquals(1, serviceCount) + } + + @Test + fun runtimeUiStarterCompletesWithoutSideEffectsForScreenshotFixture() { + val starter = MainActivityRuntimeUiStarter() + var attachCount = 0 + var serviceCount = 0 + + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = false, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + starter.onRuntimeInitialized( + ready = true, + startRuntimeUi = true, + attachRuntimeUi = { attachCount += 1 }, + startNodeService = { serviceCount += 1 }, + ) + + assertEquals(0, attachCount) + assertEquals(0, serviceCount) + } +}