1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.systemui.bouncer.ui.composable 18 19 import android.app.AlertDialog 20 import android.platform.test.annotations.MotionTest 21 import android.testing.TestableLooper.RunWithLooper 22 import android.view.View 23 import androidx.activity.BackEventCompat 24 import androidx.compose.animation.core.Animatable 25 import androidx.compose.animation.core.tween 26 import androidx.compose.foundation.layout.Box 27 import androidx.compose.material3.Text 28 import androidx.compose.runtime.Composable 29 import androidx.compose.runtime.LaunchedEffect 30 import androidx.compose.runtime.remember 31 import androidx.compose.ui.Alignment 32 import androidx.compose.ui.Modifier 33 import androidx.compose.ui.geometry.Offset 34 import androidx.compose.ui.geometry.isFinite 35 import androidx.compose.ui.geometry.isUnspecified 36 import androidx.compose.ui.semantics.SemanticsNode 37 import androidx.compose.ui.test.junit4.AndroidComposeTestRule 38 import androidx.test.ext.junit.runners.AndroidJUnit4 39 import androidx.test.filters.LargeTest 40 import com.android.compose.animation.scene.ObservableTransitionState 41 import com.android.compose.animation.scene.Scale 42 import com.android.compose.animation.scene.SceneKey 43 import com.android.compose.animation.scene.SceneScope 44 import com.android.compose.animation.scene.UserAction 45 import com.android.compose.animation.scene.UserActionResult 46 import com.android.compose.animation.scene.isElement 47 import com.android.compose.animation.scene.testing.lastAlphaForTesting 48 import com.android.compose.animation.scene.testing.lastScaleForTesting 49 import com.android.compose.theme.PlatformTheme 50 import com.android.systemui.SysuiTestCase 51 import com.android.systemui.bouncer.domain.interactor.bouncerInteractor 52 import com.android.systemui.bouncer.ui.BouncerDialogFactory 53 import com.android.systemui.bouncer.ui.viewmodel.BouncerSceneContentViewModel 54 import com.android.systemui.bouncer.ui.viewmodel.BouncerUserActionsViewModel 55 import com.android.systemui.bouncer.ui.viewmodel.bouncerSceneContentViewModel 56 import com.android.systemui.flags.EnableSceneContainer 57 import com.android.systemui.kosmos.Kosmos 58 import com.android.systemui.kosmos.Kosmos.Fixture 59 import com.android.systemui.lifecycle.ExclusiveActivatable 60 import com.android.systemui.lifecycle.rememberViewModel 61 import com.android.systemui.motion.createSysUiComposeMotionTestRule 62 import com.android.systemui.qs.ui.viewmodel.fakeQsSceneAdapter 63 import com.android.systemui.scene.domain.interactor.sceneInteractor 64 import com.android.systemui.scene.domain.startable.sceneContainerStartable 65 import com.android.systemui.scene.sceneContainerViewModelFactory 66 import com.android.systemui.scene.shared.model.SceneContainerConfig 67 import com.android.systemui.scene.shared.model.Scenes 68 import com.android.systemui.scene.shared.model.sceneDataSourceDelegator 69 import com.android.systemui.scene.ui.composable.Scene 70 import com.android.systemui.scene.ui.composable.SceneContainer 71 import com.android.systemui.scene.ui.composable.SceneContainerTransitions 72 import com.android.systemui.testKosmos 73 import kotlin.time.Duration.Companion.seconds 74 import kotlinx.coroutines.awaitCancellation 75 import kotlinx.coroutines.flow.Flow 76 import kotlinx.coroutines.flow.MutableStateFlow 77 import kotlinx.coroutines.flow.flowOf 78 import org.json.JSONObject 79 import org.junit.Before 80 import org.junit.Rule 81 import org.junit.Test 82 import org.junit.runner.RunWith 83 import org.mockito.MockitoAnnotations 84 import org.mockito.kotlin.mock 85 import platform.test.motion.compose.ComposeFeatureCaptures.positionInRoot 86 import platform.test.motion.compose.ComposeRecordingSpec 87 import platform.test.motion.compose.MotionControl 88 import platform.test.motion.compose.feature 89 import platform.test.motion.compose.recordMotion 90 import platform.test.motion.compose.runTest 91 import platform.test.motion.golden.DataPoint 92 import platform.test.motion.golden.DataPointType 93 import platform.test.motion.golden.DataPointTypes 94 import platform.test.motion.golden.FeatureCapture 95 import platform.test.motion.golden.UnknownTypeException 96 import platform.test.screenshot.DeviceEmulationSpec 97 import platform.test.screenshot.Displays.Phone 98 99 /** MotionTest for the Bouncer Predictive Back animation */ 100 @LargeTest 101 @RunWith(AndroidJUnit4::class) 102 @RunWithLooper 103 @EnableSceneContainer 104 @MotionTest 105 class BouncerPredictiveBackTest : SysuiTestCase() { 106 107 private val deviceSpec = DeviceEmulationSpec(Phone) 108 private val kosmos = testKosmos() 109 110 @get:Rule val motionTestRule = createSysUiComposeMotionTestRule(kosmos, deviceSpec) 111 private val androidComposeTestRule = 112 motionTestRule.toolkit.composeContentTestRule as AndroidComposeTestRule<*, *> 113 114 private val sceneInteractor by lazy { kosmos.sceneInteractor } 115 private val Kosmos.sceneKeys by Fixture { listOf(Scenes.Lockscreen, Scenes.Bouncer) } 116 private val Kosmos.initialSceneKey by Fixture { Scenes.Bouncer } 117 private val Kosmos.sceneContainerConfig by Fixture { 118 val navigationDistances = mapOf(Scenes.Lockscreen to 1, Scenes.Bouncer to 0) 119 SceneContainerConfig( 120 sceneKeys, 121 initialSceneKey, 122 SceneContainerTransitions, 123 emptyList(), 124 navigationDistances, 125 ) 126 } 127 private val view = mock<View>() 128 129 private val transitionState by lazy { 130 MutableStateFlow<ObservableTransitionState>( 131 ObservableTransitionState.Idle(kosmos.sceneContainerConfig.initialSceneKey) 132 ) 133 } 134 135 private val sceneContainerViewModel by lazy { 136 kosmos.sceneContainerViewModelFactory 137 .create(view) {} 138 .apply { setTransitionState(transitionState) } 139 } 140 141 private val bouncerDialogFactory = 142 object : BouncerDialogFactory { 143 override fun invoke(): AlertDialog { 144 throw AssertionError() 145 } 146 } 147 private val bouncerSceneActionsViewModelFactory = 148 object : BouncerUserActionsViewModel.Factory { 149 override fun create() = BouncerUserActionsViewModel(kosmos.bouncerInteractor) 150 } 151 private lateinit var bouncerSceneContentViewModel: BouncerSceneContentViewModel 152 private val bouncerSceneContentViewModelFactory = 153 object : BouncerSceneContentViewModel.Factory { 154 override fun create() = bouncerSceneContentViewModel 155 } 156 private val bouncerScene = 157 BouncerScene( 158 bouncerSceneActionsViewModelFactory, 159 bouncerSceneContentViewModelFactory, 160 bouncerDialogFactory, 161 ) 162 163 @Before 164 fun setUp() { 165 MockitoAnnotations.initMocks(this) 166 167 bouncerSceneContentViewModel = kosmos.bouncerSceneContentViewModel 168 169 val startable = kosmos.sceneContainerStartable 170 startable.start() 171 } 172 173 @Test 174 fun bouncerPredictiveBackMotion() = 175 motionTestRule.runTest(timeout = 30.seconds) { 176 val motion = 177 recordMotion( 178 content = { play -> 179 PlatformTheme { 180 BackGestureAnimation(play) 181 SceneContainer( 182 viewModel = 183 rememberViewModel("BouncerPredictiveBackTest") { 184 sceneContainerViewModel 185 }, 186 sceneByKey = 187 mapOf( 188 Scenes.Lockscreen to FakeLockscreen(), 189 Scenes.Bouncer to bouncerScene, 190 ), 191 initialSceneKey = Scenes.Bouncer, 192 sceneTransitions = SceneContainerTransitions, 193 overlayByKey = emptyMap(), 194 dataSourceDelegator = kosmos.sceneDataSourceDelegator, 195 qsSceneAdapter = { kosmos.fakeQsSceneAdapter }, 196 ) 197 } 198 }, 199 ComposeRecordingSpec( 200 MotionControl( 201 delayRecording = { 202 awaitCondition { 203 sceneInteractor.transitionState.value.isTransitioning() 204 } 205 } 206 ) { 207 awaitCondition { 208 sceneInteractor.transitionState.value.isIdle(Scenes.Lockscreen) 209 } 210 } 211 ) { 212 feature(isElement(Bouncer.Elements.Content), elementAlpha, "content_alpha") 213 feature(isElement(Bouncer.Elements.Content), elementScale, "content_scale") 214 feature( 215 isElement(Bouncer.Elements.Content), 216 positionInRoot, 217 "content_offset", 218 ) 219 feature( 220 isElement(Bouncer.Elements.Background), 221 elementAlpha, 222 "background_alpha", 223 ) 224 }, 225 ) 226 227 assertThat(motion).timeSeriesMatchesGolden() 228 } 229 230 @Composable 231 private fun BackGestureAnimation(play: Boolean) { 232 val backProgress = remember { Animatable(0f) } 233 234 LaunchedEffect(play) { 235 if (play) { 236 val dispatcher = androidComposeTestRule.activity.onBackPressedDispatcher 237 androidComposeTestRule.runOnUiThread { 238 dispatcher.dispatchOnBackStarted(backEvent()) 239 } 240 backProgress.animateTo( 241 targetValue = 1f, 242 animationSpec = tween(durationMillis = 500), 243 ) { 244 androidComposeTestRule.runOnUiThread { 245 dispatcher.dispatchOnBackProgressed( 246 backEvent(progress = backProgress.value) 247 ) 248 if (backProgress.value == 1f) { 249 dispatcher.onBackPressed() 250 } 251 } 252 } 253 } 254 } 255 } 256 257 private fun backEvent(progress: Float = 0f): BackEventCompat { 258 return BackEventCompat( 259 touchX = 0f, 260 touchY = 0f, 261 progress = progress, 262 swipeEdge = BackEventCompat.EDGE_LEFT, 263 ) 264 } 265 266 private class FakeLockscreen : ExclusiveActivatable(), Scene { 267 override val key: SceneKey = Scenes.Lockscreen 268 override val userActions: Flow<Map<UserAction, UserActionResult>> = flowOf() 269 270 @Composable 271 override fun SceneScope.Content(modifier: Modifier) { 272 Box(modifier = modifier, contentAlignment = Alignment.Center) { 273 Text(text = "Fake Lockscreen") 274 } 275 } 276 277 override suspend fun onActivated() = awaitCancellation() 278 } 279 280 companion object { 281 private val elementAlpha = 282 FeatureCapture<SemanticsNode, Float>("alpha") { 283 DataPoint.of(it.lastAlphaForTesting, DataPointTypes.float) 284 } 285 286 private val elementScale = 287 FeatureCapture<SemanticsNode, Scale>("scale") { 288 DataPoint.of(it.lastScaleForTesting, scale) 289 } 290 291 private val scale: DataPointType<Scale> = 292 DataPointType( 293 "scale", 294 jsonToValue = { 295 when (it) { 296 "unspecified" -> Scale.Unspecified 297 "default" -> Scale.Default 298 "zero" -> Scale.Zero 299 is JSONObject -> { 300 val pivot = it.get("pivot") 301 Scale( 302 scaleX = it.getDouble("x").toFloat(), 303 scaleY = it.getDouble("y").toFloat(), 304 pivot = 305 when (pivot) { 306 "unspecified" -> Offset.Unspecified 307 "infinite" -> Offset.Infinite 308 is JSONObject -> 309 Offset( 310 pivot.getDouble("x").toFloat(), 311 pivot.getDouble("y").toFloat(), 312 ) 313 else -> throw UnknownTypeException() 314 }, 315 ) 316 } 317 else -> throw UnknownTypeException() 318 } 319 }, 320 valueToJson = { 321 when (it) { 322 Scale.Unspecified -> "unspecified" 323 Scale.Default -> "default" 324 Scale.Zero -> "zero" 325 else -> { 326 JSONObject().apply { 327 put("x", it.scaleX) 328 put("y", it.scaleY) 329 put( 330 "pivot", 331 when { 332 it.pivot.isUnspecified -> "unspecified" 333 !it.pivot.isFinite -> "infinite" 334 else -> 335 JSONObject().apply { 336 put("x", it.pivot.x) 337 put("y", it.pivot.y) 338 } 339 }, 340 ) 341 } 342 } 343 } 344 }, 345 ) 346 } 347 } 348