test(android): cover main activity lifecycle gates (#102764)

This commit is contained in:
Peter Steinberger 2026-07-09 13:04:57 +01:00 committed by GitHub
parent d6cb18736a
commit 66db3024bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 164 additions and 21 deletions

View file

@ -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",

View file

@ -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(

View file

@ -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<Intent>()
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<Intent>()
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)
}
}