test(android): synchronize reader image decoding

The four decoded-image reader cases raced Compose's one-second waitUntil
against Base64ImageState work on Dispatchers.Default. Compose idleness and
its animation clock do not own that background decode, so scheduled run
36816025306 could time out before image semantics appeared.

Provide a composition-scoped decode dispatcher, retaining Dispatchers.Default
in production. The shared reader fixture owns a separate TestCoroutineScheduler,
starts composition, drains the real decoder, then waits for Compose publication
and layout. Assert zero images before draining and exactly one afterward;
all existing scroll and resize assertions remain intact.

A temporary CountDownLatch held decoding until showReader returned, then a
1500 ms sleep inside withContext forced the original losing interleaving.
The original coupledViewportAndImageResizeKeepsTheSelectedImageVisible case
failed with the exact 1000 ms ComposeTimeoutException. The same probe passed
all 26 reader cases after the fix. Both probe and sleep are absent from the final change.
Probe-free :app:testPlayDebugUnitTest passed all 26 reader and 15 codec cases.

Validation: check-changed, Android ktlint, git diff --check, and isolated
Codex autoreview passed with no accepted/actionable findings.

Production delta: +5 lines for the scoped dispatcher seam; test delta: +13.
Separate follow-up: ChatImagePreviewTest still warms its managed-image decoder
before a wall-clock wait; its independent fixture was not changed here.

Test cost: node scripts/run-vitest.mjs apps/android/app/src/test/java/ai/openclaw/app/ui/chat/ChatReaderScrollOwnershipLayoutTest.kt --maxWorkers=1 took 13.45 s and exited with no Kotlin tests found; Gradle reader+codec proof took 40.01 s wall, with 12.96 s in the 26-test reader class.
This commit is contained in:
Peter Steinberger 2026-09-30 22:45:32 -07:00
parent abb97239bc
commit d3fbeaac8f
No known key found for this signature in database
2 changed files with 22 additions and 4 deletions

View file

@ -6,11 +6,15 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal val LocalBase64ImageDecodeDispatcher = staticCompositionLocalOf<CoroutineDispatcher> { Dispatchers.Default }
/** Compose state for async base64 image decoding. */
internal data class Base64ImageState(
val image: ImageBitmap?,
@ -23,13 +27,14 @@ internal fun rememberBase64ImageState(
base64: String,
source: Base64ImageSource = Base64ImageSource.Inline,
): Base64ImageState {
val decodeDispatcher = LocalBase64ImageDecodeDispatcher.current
var image by remember(base64, source) { mutableStateOf<ImageBitmap?>(null) }
var failed by remember(base64, source) { mutableStateOf(false) }
LaunchedEffect(base64, source) {
LaunchedEffect(base64, source, decodeDispatcher) {
failed = false
image =
withContext(Dispatchers.Default) {
withContext(decodeDispatcher) {
try {
val bitmap = decodeBase64Bitmap(base64, source = source) ?: return@withContext null
bitmap.asImageBitmap()

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
@ -59,6 +60,8 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestCoroutineScheduler
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -76,6 +79,7 @@ import java.io.ByteArrayOutputStream
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], qualifiers = "w360dp-h800dp-420dpi", application = Application::class)
class ChatReaderScrollOwnershipLayoutTest {
private val imageDecodeDispatcher = StandardTestDispatcher(TestCoroutineScheduler())
private val animationScale =
object : MotionDurationScale {
override val scaleFactor = 1f
@ -482,7 +486,13 @@ class ChatReaderScrollOwnershipLayoutTest {
var viewportHeight by mutableStateOf(480.dp)
var viewportWidth by mutableStateOf(360.dp)
showReader(initialStreamingLines = 2, inlineImage = base64, viewportWidth = { viewportWidth }, viewportHeight = { viewportHeight })
composeRule.waitUntil { composeRule.onAllNodesWithContentDescription("image/png").fetchSemanticsNodes().size == 1 }
// Compose idleness does not include the decoder's background work. Drain it
// separately so image readiness never depends on wall time or advances reader animations.
composeRule.waitForIdle()
composeRule.onAllNodesWithContentDescription("image/png").assertCountEquals(0)
imageDecodeDispatcher.scheduler.advanceUntilIdle()
composeRule.waitForIdle()
composeRule.onAllNodesWithContentDescription("image/png").assertCountEquals(1)
val initialImage = composeRule.onNodeWithContentDescription("image/png").fetchSemanticsNode()
println("READER_IMAGE_ARMING initial=${initialImage.positionInRoot} size=${initialImage.size}")
val transcript = composeRule.onNode(hasScrollToIndexAction()).assertIsDisplayed()
@ -1024,7 +1034,10 @@ class ChatReaderScrollOwnershipLayoutTest {
val current = rememberChatReaderScrollController("animation-owner", timeline, historyLoading = historyLoading)
SideEffect { reader = current }
LaunchedEffect(Unit) { observedScale = currentCoroutineContext()[MotionDurationScale]?.scaleFactor }
CompositionLocalProvider(LocalChatReaderNavigation provides current.navigation) {
CompositionLocalProvider(
LocalChatReaderNavigation provides current.navigation,
LocalBase64ImageDecodeDispatcher provides imageDecodeDispatcher,
) {
val navigation = checkNotNull(LocalChatReaderNavigation.current)
Column(Modifier.size(360.dp, 700.dp).clipToBounds()) {
Row(horizontalArrangement = Arrangement.SpaceBetween) {