From ade9c446a5a2e3b47a6f69cad4162a44025a4e23 Mon Sep 17 00:00:00 2001 From: Igor Demin Date: Thu, 10 Nov 2022 21:53:25 +0100 Subject: [PATCH] Fix memory leak in Update effects (#325) If there are animations on the screen, after some time we will encounter performance degradation, and an increased memory consumption. This fix is similar to the other fix in the past (https://android-review.googlesource.com/c/platform/frameworks/support/+/1398690). We have to remember the lambda instead of creating it each time. Fixes https://github.com/JetBrains/compose-jb/issues/2455 Fixes https://github.com/JetBrains/compose-jb/issues/1969 The tests are not very deterministic, but it seems better to have them than not to have. The second test fails before the fix, and passes after the fix. We will see if they will be stable on our CI or not. If they will be flaky, we will tune them, or remove them. --- .../compose/ui/util/UpdateEffect.desktop.kt | 4 +- .../compose/ui/awt/ComplexApplicationTest.kt | 718 ++++++++++++++++++ .../androidx/compose/ui/window/TestUtils.kt | 3 +- .../androidx/compose/ui/res/test.png | Bin 0 -> 171 bytes 4 files changed, 723 insertions(+), 2 deletions(-) create mode 100644 compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComplexApplicationTest.kt create mode 100644 compose/ui/ui/src/desktopTest/resources/androidx/compose/ui/res/test.png diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/UpdateEffect.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/UpdateEffect.desktop.kt index 62a183c445c75..1c5b90a263538 100644 --- a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/UpdateEffect.desktop.kt +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/util/UpdateEffect.desktop.kt @@ -50,14 +50,16 @@ internal fun UpdateEffect(update: () -> Unit) { } snapshotObserver.start() + lateinit var sendUpdate: (Unit) -> Unit fun performUpdate() { snapshotObserver.observeReads( Unit, - onValueChangedForScope = { tasks.trySend(::performUpdate) } + onValueChangedForScope = sendUpdate, ) { currentUpdate() } } + sendUpdate = { tasks.trySend(::performUpdate) } performUpdate() diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComplexApplicationTest.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComplexApplicationTest.kt new file mode 100644 index 0000000000000..5624d725ffc53 --- /dev/null +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/awt/ComplexApplicationTest.kt @@ -0,0 +1,718 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package androidx.compose.ui.awt + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.TweenSpec +import androidx.compose.foundation.ContextMenuDataProvider +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.mouseClickable +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.material.BottomAppBar +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.ExtendedFloatingActionButton +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Slider +import androidx.compose.material.Switch +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.pointer.isAltPressed +import androidx.compose.ui.input.pointer.isCtrlPressed +import androidx.compose.ui.input.pointer.isMetaPressed +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.isShiftPressed +import androidx.compose.ui.input.pointer.isTertiaryPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerIconDefaults +import androidx.compose.ui.input.pointer.isBackPressed +import androidx.compose.ui.input.pointer.isForwardPressed +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.platform.Font +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextDecoration.Companion.Underline +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.em +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.FrameWindowScope +import androidx.compose.ui.window.WindowState +import androidx.compose.ui.window.awaitApplication +import androidx.compose.ui.window.launchApplication +import androidx.compose.ui.window.rememberWindowState +import androidx.compose.ui.window.runApplicationTest +import com.google.common.truth.Truth +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlin.random.Random +import kotlinx.coroutines.delay +import org.junit.Test + +private const val title = "Desktop Compose Elements" + +private val isCtrlPressed = mutableStateOf(false) + +@Composable +private fun FrameWindowScope.App() { + val uriHandler = LocalUriHandler.current + MaterialTheme { + Scaffold( + topBar = { + WindowDraggableArea { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painterResource("androidx/compose/ui/res/star-size-100.svg"), + contentDescription = "Star" + ) + Text(title) + } + } + ) + } + }, + floatingActionButton = { + ExtendedFloatingActionButton( + text = { Text("Open URL") }, + onClick = { + uriHandler.openUri("https://google.com") + } + ) + }, + isFloatingActionButtonDocked = true, + bottomBar = { + BottomAppBar(cutoutShape = CircleShape) { + IconButton( + onClick = {} + ) { + Icon(Icons.Filled.Menu, "Menu", Modifier.size(ButtonDefaults.IconSize)) + } + } + }, + content = { innerPadding -> + Row(Modifier.padding(innerPadding)) { + LeftColumn(Modifier.weight(1f)) + RightColumn(Modifier.width(200.dp)) + } + } + ) + } +} + +@Composable +private fun FrameWindowScope.LeftColumn(modifier: Modifier) = Box(modifier.fillMaxSize()) { + val state = rememberScrollState() + ScrollableContent(state) + + VerticalScrollbar( + rememberScrollbarAdapter(state), + Modifier.align(Alignment.CenterEnd).fillMaxHeight() + ) +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun FrameWindowScope.ScrollableContent(scrollState: ScrollState) { + val amount = remember { mutableStateOf(0f) } + val animation = remember { mutableStateOf(true) } + Column(Modifier.fillMaxSize().verticalScroll(scrollState)) { + val info = "${window.renderApi} (${window.windowHandle})" + Text( + text = "Привет! 你好! Desktop Compose use $info: ${amount.value}", + color = Color.Black, + modifier = Modifier + .background(Color.Blue) + .height(56.dp) + .wrapContentSize(Alignment.Center) + ) + + val inlineIndicatorId = "indicator" + + Text( + text = buildAnnotatedString { + append("The quick ") + if (animation.value) { + appendInlineContent(inlineIndicatorId) + } + pushStyle( + SpanStyle( + color = Color(0xff964B00), + shadow = Shadow(Color.Green, offset = Offset(1f, 1f)) + ) + ) + append("brown fox") + pop() + pushStyle(SpanStyle(background = Color.Yellow)) + append(" 🦊 ate a ") + pop() + pushStyle(SpanStyle(fontSize = 30.sp, textDecoration = Underline)) + append("zesty hamburgerfons") + pop() + append(" 🍔.\nThe 👩‍👩‍👧‍👧 laughed.") + addStyle(SpanStyle(color = Color.Green), 25, 35) + }, + color = Color.Black, + inlineContent = mapOf( + inlineIndicatorId to InlineTextContent( + Placeholder( + width = 1.em, + height = 1.em, + placeholderVerticalAlign = PlaceholderVerticalAlign.AboveBaseline + ) + ) { + CircularProgressIndicator(Modifier.padding(end = 3.dp)) + } + ) + ) + + val loremColors = listOf( + Color.Black, + Color.Yellow, + Color.Green, + Color.Blue + ) + var loremColor by remember { mutableStateOf(0) } + + val loremDecorations = listOf( + TextDecoration.None, + TextDecoration.Underline, + TextDecoration.LineThrough + ) + val lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do" + + " eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad" + + " minim veniam, quis nostrud exercitation ullamco laboris nisi ut" + + " aliquipex ea commodo consequat. Duis aute irure dolor in reprehenderit" + + " in voluptate velit esse cillum dolore eu fugiat nulla pariatur." + + " Excepteur" + + " sint occaecat cupidatat non proident, sunt in culpa qui officia" + + " deserunt mollit anim id est laborum." + var loremDecoration by remember { mutableStateOf(0) } + Text( + text = lorem, + color = loremColors[loremColor], + textAlign = TextAlign.Center, + textDecoration = loremDecorations[loremDecoration], + modifier = Modifier.clickable { + if (loremColor < loremColors.size - 1) { + loremColor += 1 + } else { + loremColor = 0 + } + + if (loremDecoration < loremDecorations.size - 1) { + loremDecoration += 1 + } else { + loremDecoration = 0 + } + } + ) + + Text( + text = lorem, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Text( + "Default", + ) + + Text( + "SansSerif", + fontFamily = FontFamily.SansSerif + ) + + Text( + "Serif", + fontFamily = FontFamily.Serif + ) + + Text( + "Monospace", + fontFamily = FontFamily.Monospace + ) + + Text( + "Cursive", + fontFamily = FontFamily.Cursive + ) + } + + var overText by remember { mutableStateOf("Move mouse over text:") } + Text(overText, style = TextStyle(letterSpacing = 10.sp)) + + SelectionContainer { + Text( + text = "fun > List.quickSort(): List = when {\n" + + " size < 2 -> this\n" + + " else -> {\n" + + " val pivot = first()\n" + + " val (smaller, greater) = drop(1).partition { it <= pivot }\n" + + " smaller.quickSort() + pivot + greater.quickSort()\n" + + " }\n" + + "}", + modifier = Modifier + .padding(10.dp) + .onPointerEvent(PointerEventType.Move) { + val position = it.changes.first().position + overText = "Move position: $position" + } + .onPointerEvent(PointerEventType.Enter) { + overText = "Over enter" + } + .onPointerEvent(PointerEventType.Exit) { + overText = "Over exit" + } + ) + } + Text( + text = buildAnnotatedString { + append("resolved: NotoSans-Regular.ttf ") + pushStyle( + SpanStyle( + fontStyle = FontStyle.Italic + ) + ) + append("NotoSans-italic.ttf.") + }, + ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Button( + modifier = Modifier.padding(4.dp).pointerHoverIcon(PointerIconDefaults.Hand), + onClick = { + amount.value++ + } + ) { + Text("Base") + } + + var clickableText by remember { mutableStateOf("Click me!") } + @OptIn(ExperimentalFoundationApi::class) + Text( + modifier = Modifier.mouseClickable( + onClick = { + clickableText = buildString { + append("Buttons pressed:\n") + append("primary: ${buttons.isPrimaryPressed}\t") + append("secondary: ${buttons.isSecondaryPressed}\t") + append("tertiary: ${buttons.isTertiaryPressed}\t") + append("primary: ${buttons.isPrimaryPressed}\t") + append("back: ${buttons.isBackPressed}\t") + append("forward: ${buttons.isForwardPressed}\t") + + append("\n\nKeyboard modifiers pressed:\n") + + append("alt: ${keyboardModifiers.isAltPressed}\t") + append("ctrl: ${keyboardModifiers.isCtrlPressed}\t") + append("meta: ${keyboardModifiers.isMetaPressed}\t") + append("shift: ${keyboardModifiers.isShiftPressed}\t") + } + } + ), + text = clickableText + ) + } + + Row( + modifier = Modifier.padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Row { + Column { + Switch( + animation.value, + onCheckedChange = { + animation.value = it + } + ) + Row( + modifier = Modifier.padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + animation.value, + onCheckedChange = { + animation.value = it + } + ) + Text("Animation") + } + } + + Button( + modifier = Modifier.padding(4.dp), + onClick = { + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launchApplication { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(size = DpSize(400.dp, 200.dp)), + onPreviewKeyEvent = { + if (it.key == Key.Escape) { + exitApplication() + true + } else { + false + } + } + ) { + Animations(isCircularEnabled = animation.value) + } + } + } + ) { + Text("Window") + } + } + + Animations(isCircularEnabled = animation.value) + } + + Slider( + value = amount.value / 100f, + onValueChange = { amount.value = (it * 100) } + ) + val dropDownMenuExpanded = remember { mutableStateOf(false) } + Button(onClick = { dropDownMenuExpanded.value = true }) { + Text("Expand Menu") + } + DropdownMenu( + expanded = dropDownMenuExpanded.value, + onDismissRequest = { + dropDownMenuExpanded.value = false + println("OnDismissRequest") + } + ) { + DropdownMenuItem(modifier = Modifier, onClick = { + println("Item 1 clicked") + }) { + Text("Item 1") + } + DropdownMenuItem(modifier = Modifier, onClick = { + println("Item 2 clicked") + }) { + Text("Item 2") + } + DropdownMenuItem(modifier = Modifier, onClick = { + println("Item 3 clicked") + }) { + Text("Item 3") + } + } + TextField( + value = amount.value.toString(), + onValueChange = { amount.value = it.toFloatOrNull() ?: 42f }, + label = { Text(text = "Input1") } + ) + + val (focusItem1, focusItem2) = FocusRequester.createRefs() + val text = remember { + mutableStateOf("Hello \uD83E\uDDD1\uD83C\uDFFF\u200D\uD83E\uDDB0") + } + ContextMenuDataProvider( + items = { + listOf(ContextMenuItem("Clear") { text.value = ""; focusItem1.requestFocus() }) + } + ) { + TextField( + value = text.value, + onValueChange = { text.value = it }, + label = { Text(text = "Input2") }, + placeholder = { + Text(text = "Important input") + }, + maxLines = 1, + modifier = Modifier.onPreviewKeyEvent { + when { + (it.isMetaPressed && it.key == Key.Enter) -> { + if (it.isShiftPressed) { + text.value = "Cleared with shift!" + } else { + text.value = "Cleared!" + } + true + } + else -> false + } + }.focusRequester(focusItem1) + .focusProperties { + next = focusItem2 + } + ) + } + + var text2 by remember { + val initText = buildString { + (1..1000).forEach { + append("$it\n") + } + } + mutableStateOf(initText) + } + TextField( + text2, + modifier = Modifier + .height(200.dp) + .focusRequester(focusItem2) + .focusProperties { + previous = focusItem1 + }, + onValueChange = { text2 = it } + ) + + Row { + Image( + painterResource("androidx/compose/ui/res/test.png"), + "Localized description", + Modifier.size(200.dp) + ) + } + + Box( + modifier = Modifier.size(150.dp).background(Color.Gray).pointerHoverIcon( + if (isCtrlPressed.value) PointerIconDefaults.Hand else PointerIconDefaults.Default + ) + ) { + Box( + modifier = Modifier.offset(20.dp, 20.dp).size(100.dp).background(Color.Blue).pointerHoverIcon( + if (isCtrlPressed.value) PointerIconDefaults.Crosshair else PointerIconDefaults.Text, + ) + ) { + Text("pointerHoverIcon test with Ctrl") + } + } + } +} + +@Composable +fun Animations(isCircularEnabled: Boolean) = Row { + if (isCircularEnabled) { + CircularProgressIndicator(Modifier.padding(10.dp)) + } + + val enabled = remember { mutableStateOf(true) } + val color by animateColorAsState( + if (enabled.value) Color.Green else Color.Red, + animationSpec = TweenSpec(durationMillis = 2000) + ) + + MaterialTheme { + Box( + Modifier + .size(70.dp) + .clickable { enabled.value = !enabled.value } + .background(color) + ) + } +} + +@Composable +private fun RightColumn(modifier: Modifier) = Box { + val state = rememberLazyListState() + val itemCount = 100000 + val heights = remember { + val random = Random(24) + (0 until itemCount).map { random.nextFloat() } + } + + LazyColumn(modifier.graphicsLayer(alpha = 0.5f), state = state) { + items((0 until itemCount).toList()) { i -> + val itemHeight = 20.dp + 20.dp * heights[i] + Text(i.toString(), Modifier.graphicsLayer(alpha = 0.5f).height(itemHeight)) + } + } + + VerticalScrollbar( + rememberScrollbarAdapter(state), + Modifier.align(Alignment.CenterEnd) + ) +} + +@Composable +fun AppWindow() { + Window( + onCloseRequest = {}, + title = title, + state = rememberWindowState(width = 1024.dp, height = 850.dp), + onPreviewKeyEvent = { + isCtrlPressed.value = it.isCtrlPressed + false + } + ) { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + App() + } + } +} + +private suspend fun performGC() { + repeat(10) { + System.gc() + delay(100) + } + delay(5000) +} + +private val availableMemory get() = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory() + +class ComplexApplicationTest { + @Test + fun `no memory leak when open window multiple times`() = runApplicationTest( + timeoutMillis = 10 * 60 * 1000 + ) { + repeat(10) { + awaitApplication { + AppWindow() + LaunchedEffect(Unit) { + delay(1000) + exitApplication() + } + } + } + + performGC() + val oldMemory = availableMemory + + repeat(10) { + awaitApplication { + AppWindow() + LaunchedEffect(Unit) { + delay(1000) + exitApplication() + } + } + } + + performGC() + val newMemory = availableMemory + + Truth + .assertWithMessage("Memory is increased more than 5% after opening multiple windows") + .that(newMemory < 1.05 * oldMemory) + .isTrue() + } + + @Test + fun `no memory leak when wait 3 minutes`() = runApplicationTest( + timeoutMillis = 10 * 60 * 1000 + ) { + launchApplication { + AppWindow() + } + + delay(30 * 1000) + + performGC() + val oldMemory = availableMemory + + delay(3 * 60 * 1000) + + performGC() + val newMemory = availableMemory + + Truth + .assertWithMessage("Memory is increased more than 5% after waiting a few minutes") + .that(newMemory < 1.05 * oldMemory) + .isTrue() + + exitApplication() + } +} \ No newline at end of file diff --git a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt index 5c6c529b5bb1c..00110f5653a67 100644 --- a/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt +++ b/compose/ui/ui/src/desktopTest/kotlin/androidx/compose/ui/window/TestUtils.kt @@ -48,12 +48,13 @@ internal fun runApplicationTest( * non-deterministic way when we change position/size very fast (see the snippet below) */ useDelay: Boolean = false, + timeoutMillis: Long = 30000, body: suspend WindowTestScope.() -> Unit ) { assumeFalse(GraphicsEnvironment.getLocalGraphicsEnvironment().isHeadlessInstance) runBlocking(MainUIDispatcher) { - withTimeout(30000) { + withTimeout(timeoutMillis) { val exceptionHandler = TestExceptionHandler() withExceptionHandler(exceptionHandler) { val scope = WindowTestScope(this, useDelay, exceptionHandler) diff --git a/compose/ui/ui/src/desktopTest/resources/androidx/compose/ui/res/test.png b/compose/ui/ui/src/desktopTest/resources/androidx/compose/ui/res/test.png new file mode 100644 index 0000000000000000000000000000000000000000..b1859b93bab41007a9833ad1d05c70ff400cca31 GIT binary patch literal 171 zcmeAS@N?(olHy`uVBq!ia0vp^93afW1|*O0@9PFqjKx9jP7LeL$-D$|SkfJR9T^xl z_H+M9WCij$3p^r=85sBugD~Uq{1qucK?hG4#}JL+-fdK=