Loading src/com/android/settings/spa/app/appinfo/AppButtons.kt +20 −4 Original line number Original line Diff line number Diff line Loading @@ -20,18 +20,25 @@ import android.content.pm.PackageInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.remember import com.android.settingslib.applications.AppUtils import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle import com.android.settingslib.spa.widget.button.ActionButton import com.android.settingslib.spa.widget.button.ActionButton import com.android.settingslib.spa.widget.button.ActionButtons import com.android.settingslib.spa.widget.button.ActionButtons import com.android.settingslib.spaprivileged.model.app.isSystemModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @Composable @Composable fun AppButtons(packageInfoPresenter: PackageInfoPresenter) { fun AppButtons(packageInfoPresenter: PackageInfoPresenter) { val appButtonsHolder = remember { AppButtonsHolder(packageInfoPresenter) } val presenter = remember { AppButtonsPresenter(packageInfoPresenter) } appButtonsHolder.Dialogs() if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return ActionButtons(actionButtons = appButtonsHolder.rememberActionsButtons().value) presenter.Dialogs() ActionButtons(actionButtons = presenter.rememberActionsButtons().value) } } private class AppButtonsHolder(private val packageInfoPresenter: PackageInfoPresenter) { private class AppButtonsPresenter(private val packageInfoPresenter: PackageInfoPresenter) { private val appLaunchButton = AppLaunchButton(packageInfoPresenter) private val appLaunchButton = AppLaunchButton(packageInfoPresenter) private val appInstallButton = AppInstallButton(packageInfoPresenter) private val appInstallButton = AppInstallButton(packageInfoPresenter) private val appDisableButton = AppDisableButton(packageInfoPresenter) private val appDisableButton = AppDisableButton(packageInfoPresenter) Loading @@ -39,6 +46,15 @@ private class AppButtonsHolder(private val packageInfoPresenter: PackageInfoPres private val appClearButton = AppClearButton(packageInfoPresenter) private val appClearButton = AppClearButton(packageInfoPresenter) private val appForceStopButton = AppForceStopButton(packageInfoPresenter) private val appForceStopButton = AppForceStopButton(packageInfoPresenter) val isAvailableFlow = flow { emit(isAvailable()) } private suspend fun isAvailable(): Boolean = withContext(Dispatchers.IO) { !packageInfoPresenter.userPackageManager.isSystemModule(packageInfoPresenter.packageName) && !AppUtils.isMainlineModule( packageInfoPresenter.userPackageManager, packageInfoPresenter.packageName ) } @Composable @Composable fun rememberActionsButtons() = remember { fun rememberActionsButtons() = remember { packageInfoPresenter.flow.map { packageInfo -> packageInfoPresenter.flow.map { packageInfo -> Loading src/com/android/settings/spa/app/appinfo/AppLaunchButton.kt +2 −2 Original line number Original line Diff line number Diff line Loading @@ -27,10 +27,10 @@ import com.android.settingslib.spaprivileged.model.app.userHandle class AppLaunchButton(packageInfoPresenter: PackageInfoPresenter) { class AppLaunchButton(packageInfoPresenter: PackageInfoPresenter) { private val context = packageInfoPresenter.context private val context = packageInfoPresenter.context private val packageManagerAsUser = packageInfoPresenter.packageManagerAsUser private val userPackageManager = packageInfoPresenter.userPackageManager fun getActionButton(packageInfo: PackageInfo): ActionButton? = fun getActionButton(packageInfo: PackageInfo): ActionButton? = packageManagerAsUser.getLaunchIntentForPackage(packageInfo.packageName)?.let { intent -> userPackageManager.getLaunchIntentForPackage(packageInfo.packageName)?.let { intent -> launchButton(intent, packageInfo.applicationInfo) launchButton(intent, packageInfo.applicationInfo) } } Loading src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt +4 −4 Original line number Original line Diff line number Diff line Loading @@ -51,7 +51,7 @@ class PackageInfoPresenter( ) { ) { private val metricsFeatureProvider = FeatureFactory.getFactory(context).metricsFeatureProvider private val metricsFeatureProvider = FeatureFactory.getFactory(context).metricsFeatureProvider val userContext by lazy { context.asUser(UserHandle.of(userId)) } val userContext by lazy { context.asUser(UserHandle.of(userId)) } val packageManagerAsUser: PackageManager by lazy { userContext.packageManager } val userPackageManager: PackageManager by lazy { userContext.packageManager } private val _flow: MutableStateFlow<PackageInfo?> = MutableStateFlow(null) private val _flow: MutableStateFlow<PackageInfo?> = MutableStateFlow(null) val flow: StateFlow<PackageInfo?> = _flow val flow: StateFlow<PackageInfo?> = _flow Loading Loading @@ -92,7 +92,7 @@ class PackageInfoPresenter( fun enable() { fun enable() { logAction(SettingsEnums.ACTION_SETTINGS_ENABLE_APP) logAction(SettingsEnums.ACTION_SETTINGS_ENABLE_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.setApplicationEnabledSetting( userPackageManager.setApplicationEnabledSetting( packageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, 0 packageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, 0 ) ) notifyChange() notifyChange() Loading @@ -103,7 +103,7 @@ class PackageInfoPresenter( fun disable() { fun disable() { logAction(SettingsEnums.ACTION_SETTINGS_DISABLE_APP) logAction(SettingsEnums.ACTION_SETTINGS_DISABLE_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.setApplicationEnabledSetting( userPackageManager.setApplicationEnabledSetting( packageName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, 0 packageName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, 0 ) ) notifyChange() notifyChange() Loading @@ -124,7 +124,7 @@ class PackageInfoPresenter( fun clearInstantApp() { fun clearInstantApp() { logAction(SettingsEnums.ACTION_SETTINGS_CLEAR_INSTANT_APP) logAction(SettingsEnums.ACTION_SETTINGS_CLEAR_INSTANT_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.deletePackageAsUser(packageName, null, 0, userId) userPackageManager.deletePackageAsUser(packageName, null, 0, userId) notifyChange() notifyChange() } } } } Loading tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt 0 → 100644 +131 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 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 com.android.settings.spa.app.appinfo import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.ModuleInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.dx.mockito.inline.extended.ExtendedMockito import com.android.settings.testutils.delay import com.android.settingslib.applications.AppUtils import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.doReturn import org.mockito.Mockito.doThrow import org.mockito.MockitoSession import org.mockito.Spy import org.mockito.quality.Strictness import org.mockito.Mockito.`when` as whenever @RunWith(AndroidJUnit4::class) class AppButtonsTest { @get:Rule val composeTestRule = createComposeRule() private lateinit var mockSession: MockitoSession @Spy private val context: Context = ApplicationProvider.getApplicationContext() @Mock private lateinit var packageInfoPresenter: PackageInfoPresenter @Mock private lateinit var packageManager: PackageManager @Before fun setUp() { mockSession = ExtendedMockito.mockitoSession() .initMocks(this) .mockStatic(AppUtils::class.java) .strictness(Strictness.LENIENT) .startMocking() whenever(packageInfoPresenter.context).thenReturn(context) whenever(packageInfoPresenter.packageName).thenReturn(PACKAGE_NAME) whenever(packageInfoPresenter.userPackageManager).thenReturn(packageManager) doThrow(NameNotFoundException()).`when`(packageManager).getModuleInfo(PACKAGE_NAME, 0) whenever(packageManager.getPackageInfo(PACKAGE_NAME, 0)).thenReturn(PACKAGE_INFO) whenever(AppUtils.isMainlineModule(packageManager, PACKAGE_NAME)).thenReturn(false) } @After fun tearDown() { mockSession.finishMocking() } @Test fun isSystemModule_notDisplayed() { doReturn(ModuleInfo()).`when`(packageManager).getModuleInfo(PACKAGE_NAME, 0) setContent() composeTestRule.onRoot().assertIsNotDisplayed() } @Test fun isMainlineModule_notDisplayed() { whenever(AppUtils.isMainlineModule(packageManager, PACKAGE_NAME)).thenReturn(true) setContent() composeTestRule.onRoot().assertIsNotDisplayed() } @Test fun isNormalApp_displayed() { setContent() composeTestRule.onRoot().assertIsDisplayed() } private fun setContent() { composeTestRule.setContent { val scope = rememberCoroutineScope() LaunchedEffect(Unit) { whenever(packageInfoPresenter.flow).thenReturn(flowOf(PACKAGE_INFO).stateIn(scope)) } AppButtons(packageInfoPresenter) } composeTestRule.delay() } private companion object { const val PACKAGE_NAME = "package.name" val PACKAGE_INFO = PackageInfo().apply { applicationInfo = ApplicationInfo() } } } tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt +8 −0 Original line number Original line Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.settings.testutils package com.android.settings.testutils import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule Loading @@ -23,3 +24,10 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } } /** Blocks until the timeout is reached. */ fun ComposeContentTestRule.delay(timeoutMillis: Long = 1_000) = try { waitUntil(timeoutMillis) { false } } catch (_: ComposeTimeoutException) { // Expected } Loading
src/com/android/settings/spa/app/appinfo/AppButtons.kt +20 −4 Original line number Original line Diff line number Diff line Loading @@ -20,18 +20,25 @@ import android.content.pm.PackageInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.remember import androidx.compose.runtime.remember import com.android.settingslib.applications.AppUtils import com.android.settingslib.spa.framework.compose.collectAsStateWithLifecycle import com.android.settingslib.spa.widget.button.ActionButton import com.android.settingslib.spa.widget.button.ActionButton import com.android.settingslib.spa.widget.button.ActionButtons import com.android.settingslib.spa.widget.button.ActionButtons import com.android.settingslib.spaprivileged.model.app.isSystemModule import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @Composable @Composable fun AppButtons(packageInfoPresenter: PackageInfoPresenter) { fun AppButtons(packageInfoPresenter: PackageInfoPresenter) { val appButtonsHolder = remember { AppButtonsHolder(packageInfoPresenter) } val presenter = remember { AppButtonsPresenter(packageInfoPresenter) } appButtonsHolder.Dialogs() if (!presenter.isAvailableFlow.collectAsStateWithLifecycle(initialValue = false).value) return ActionButtons(actionButtons = appButtonsHolder.rememberActionsButtons().value) presenter.Dialogs() ActionButtons(actionButtons = presenter.rememberActionsButtons().value) } } private class AppButtonsHolder(private val packageInfoPresenter: PackageInfoPresenter) { private class AppButtonsPresenter(private val packageInfoPresenter: PackageInfoPresenter) { private val appLaunchButton = AppLaunchButton(packageInfoPresenter) private val appLaunchButton = AppLaunchButton(packageInfoPresenter) private val appInstallButton = AppInstallButton(packageInfoPresenter) private val appInstallButton = AppInstallButton(packageInfoPresenter) private val appDisableButton = AppDisableButton(packageInfoPresenter) private val appDisableButton = AppDisableButton(packageInfoPresenter) Loading @@ -39,6 +46,15 @@ private class AppButtonsHolder(private val packageInfoPresenter: PackageInfoPres private val appClearButton = AppClearButton(packageInfoPresenter) private val appClearButton = AppClearButton(packageInfoPresenter) private val appForceStopButton = AppForceStopButton(packageInfoPresenter) private val appForceStopButton = AppForceStopButton(packageInfoPresenter) val isAvailableFlow = flow { emit(isAvailable()) } private suspend fun isAvailable(): Boolean = withContext(Dispatchers.IO) { !packageInfoPresenter.userPackageManager.isSystemModule(packageInfoPresenter.packageName) && !AppUtils.isMainlineModule( packageInfoPresenter.userPackageManager, packageInfoPresenter.packageName ) } @Composable @Composable fun rememberActionsButtons() = remember { fun rememberActionsButtons() = remember { packageInfoPresenter.flow.map { packageInfo -> packageInfoPresenter.flow.map { packageInfo -> Loading
src/com/android/settings/spa/app/appinfo/AppLaunchButton.kt +2 −2 Original line number Original line Diff line number Diff line Loading @@ -27,10 +27,10 @@ import com.android.settingslib.spaprivileged.model.app.userHandle class AppLaunchButton(packageInfoPresenter: PackageInfoPresenter) { class AppLaunchButton(packageInfoPresenter: PackageInfoPresenter) { private val context = packageInfoPresenter.context private val context = packageInfoPresenter.context private val packageManagerAsUser = packageInfoPresenter.packageManagerAsUser private val userPackageManager = packageInfoPresenter.userPackageManager fun getActionButton(packageInfo: PackageInfo): ActionButton? = fun getActionButton(packageInfo: PackageInfo): ActionButton? = packageManagerAsUser.getLaunchIntentForPackage(packageInfo.packageName)?.let { intent -> userPackageManager.getLaunchIntentForPackage(packageInfo.packageName)?.let { intent -> launchButton(intent, packageInfo.applicationInfo) launchButton(intent, packageInfo.applicationInfo) } } Loading
src/com/android/settings/spa/app/appinfo/PackageInfoPresenter.kt +4 −4 Original line number Original line Diff line number Diff line Loading @@ -51,7 +51,7 @@ class PackageInfoPresenter( ) { ) { private val metricsFeatureProvider = FeatureFactory.getFactory(context).metricsFeatureProvider private val metricsFeatureProvider = FeatureFactory.getFactory(context).metricsFeatureProvider val userContext by lazy { context.asUser(UserHandle.of(userId)) } val userContext by lazy { context.asUser(UserHandle.of(userId)) } val packageManagerAsUser: PackageManager by lazy { userContext.packageManager } val userPackageManager: PackageManager by lazy { userContext.packageManager } private val _flow: MutableStateFlow<PackageInfo?> = MutableStateFlow(null) private val _flow: MutableStateFlow<PackageInfo?> = MutableStateFlow(null) val flow: StateFlow<PackageInfo?> = _flow val flow: StateFlow<PackageInfo?> = _flow Loading Loading @@ -92,7 +92,7 @@ class PackageInfoPresenter( fun enable() { fun enable() { logAction(SettingsEnums.ACTION_SETTINGS_ENABLE_APP) logAction(SettingsEnums.ACTION_SETTINGS_ENABLE_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.setApplicationEnabledSetting( userPackageManager.setApplicationEnabledSetting( packageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, 0 packageName, PackageManager.COMPONENT_ENABLED_STATE_DEFAULT, 0 ) ) notifyChange() notifyChange() Loading @@ -103,7 +103,7 @@ class PackageInfoPresenter( fun disable() { fun disable() { logAction(SettingsEnums.ACTION_SETTINGS_DISABLE_APP) logAction(SettingsEnums.ACTION_SETTINGS_DISABLE_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.setApplicationEnabledSetting( userPackageManager.setApplicationEnabledSetting( packageName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, 0 packageName, PackageManager.COMPONENT_ENABLED_STATE_DISABLED_USER, 0 ) ) notifyChange() notifyChange() Loading @@ -124,7 +124,7 @@ class PackageInfoPresenter( fun clearInstantApp() { fun clearInstantApp() { logAction(SettingsEnums.ACTION_SETTINGS_CLEAR_INSTANT_APP) logAction(SettingsEnums.ACTION_SETTINGS_CLEAR_INSTANT_APP) coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) { packageManagerAsUser.deletePackageAsUser(packageName, null, 0, userId) userPackageManager.deletePackageAsUser(packageName, null, 0, userId) notifyChange() notifyChange() } } } } Loading
tests/spa_unit/src/com/android/settings/spa/app/appinfo/AppButtonsTest.kt 0 → 100644 +131 −0 Original line number Original line Diff line number Diff line /* * Copyright (C) 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 com.android.settings.spa.app.appinfo import android.content.Context import android.content.pm.ApplicationInfo import android.content.pm.ModuleInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.PackageManager.NameNotFoundException import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onRoot import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.dx.mockito.inline.extended.ExtendedMockito import com.android.settings.testutils.delay import com.android.settingslib.applications.AppUtils import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mock import org.mockito.Mockito.doReturn import org.mockito.Mockito.doThrow import org.mockito.MockitoSession import org.mockito.Spy import org.mockito.quality.Strictness import org.mockito.Mockito.`when` as whenever @RunWith(AndroidJUnit4::class) class AppButtonsTest { @get:Rule val composeTestRule = createComposeRule() private lateinit var mockSession: MockitoSession @Spy private val context: Context = ApplicationProvider.getApplicationContext() @Mock private lateinit var packageInfoPresenter: PackageInfoPresenter @Mock private lateinit var packageManager: PackageManager @Before fun setUp() { mockSession = ExtendedMockito.mockitoSession() .initMocks(this) .mockStatic(AppUtils::class.java) .strictness(Strictness.LENIENT) .startMocking() whenever(packageInfoPresenter.context).thenReturn(context) whenever(packageInfoPresenter.packageName).thenReturn(PACKAGE_NAME) whenever(packageInfoPresenter.userPackageManager).thenReturn(packageManager) doThrow(NameNotFoundException()).`when`(packageManager).getModuleInfo(PACKAGE_NAME, 0) whenever(packageManager.getPackageInfo(PACKAGE_NAME, 0)).thenReturn(PACKAGE_INFO) whenever(AppUtils.isMainlineModule(packageManager, PACKAGE_NAME)).thenReturn(false) } @After fun tearDown() { mockSession.finishMocking() } @Test fun isSystemModule_notDisplayed() { doReturn(ModuleInfo()).`when`(packageManager).getModuleInfo(PACKAGE_NAME, 0) setContent() composeTestRule.onRoot().assertIsNotDisplayed() } @Test fun isMainlineModule_notDisplayed() { whenever(AppUtils.isMainlineModule(packageManager, PACKAGE_NAME)).thenReturn(true) setContent() composeTestRule.onRoot().assertIsNotDisplayed() } @Test fun isNormalApp_displayed() { setContent() composeTestRule.onRoot().assertIsDisplayed() } private fun setContent() { composeTestRule.setContent { val scope = rememberCoroutineScope() LaunchedEffect(Unit) { whenever(packageInfoPresenter.flow).thenReturn(flowOf(PACKAGE_INFO).stateIn(scope)) } AppButtons(packageInfoPresenter) } composeTestRule.delay() } private companion object { const val PACKAGE_NAME = "package.name" val PACKAGE_INFO = PackageInfo().apply { applicationInfo = ApplicationInfo() } } }
tests/spa_unit/src/com/android/settings/testutils/ComposeContentTestRuleExt.kt +8 −0 Original line number Original line Diff line number Diff line Loading @@ -16,6 +16,7 @@ package com.android.settings.testutils package com.android.settings.testutils import androidx.compose.ui.test.ComposeTimeoutException import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.ComposeContentTestRule Loading @@ -23,3 +24,10 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { fun ComposeContentTestRule.waitUntilExists(matcher: SemanticsMatcher) = waitUntil { onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } } /** Blocks until the timeout is reached. */ fun ComposeContentTestRule.delay(timeoutMillis: Long = 1_000) = try { waitUntil(timeoutMillis) { false } } catch (_: ComposeTimeoutException) { // Expected }