1 /* 2 * Copyright (C) 2023 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.shade 18 19 import android.graphics.Rect 20 import android.os.PowerManager 21 import android.platform.test.annotations.DisableFlags 22 import android.platform.test.annotations.EnableFlags 23 import android.testing.AndroidTestingRunner 24 import android.testing.TestableLooper 25 import android.testing.ViewUtils 26 import android.view.MotionEvent 27 import android.view.View 28 import android.widget.FrameLayout 29 import androidx.lifecycle.Lifecycle 30 import androidx.lifecycle.LifecycleOwner 31 import androidx.test.filters.SmallTest 32 import com.android.compose.animation.scene.ObservableTransitionState 33 import com.android.compose.animation.scene.SceneKey 34 import com.android.systemui.Flags 35 import com.android.systemui.Flags.FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE 36 import com.android.systemui.SysuiTestCase 37 import com.android.systemui.ambient.touch.TouchHandler 38 import com.android.systemui.ambient.touch.TouchMonitor 39 import com.android.systemui.ambient.touch.dagger.AmbientTouchComponent 40 import com.android.systemui.bouncer.data.repository.fakeKeyguardBouncerRepository 41 import com.android.systemui.communal.data.repository.FakeCommunalSceneRepository 42 import com.android.systemui.communal.data.repository.fakeCommunalSceneRepository 43 import com.android.systemui.communal.domain.interactor.communalInteractor 44 import com.android.systemui.communal.domain.interactor.setCommunalAvailable 45 import com.android.systemui.communal.shared.model.CommunalScenes 46 import com.android.systemui.communal.ui.compose.CommunalContent 47 import com.android.systemui.communal.ui.viewmodel.CommunalViewModel 48 import com.android.systemui.communal.util.CommunalColors 49 import com.android.systemui.coroutines.collectLastValue 50 import com.android.systemui.keyguard.domain.interactor.keyguardInteractor 51 import com.android.systemui.kosmos.Kosmos 52 import com.android.systemui.kosmos.testDispatcher 53 import com.android.systemui.kosmos.testScope 54 import com.android.systemui.res.R 55 import com.android.systemui.scene.shared.model.sceneDataSourceDelegator 56 import com.android.systemui.shade.domain.interactor.shadeInteractor 57 import com.android.systemui.statusbar.notification.stack.notificationStackScrollLayoutController 58 import com.android.systemui.testKosmos 59 import com.android.systemui.util.mockito.any 60 import com.google.common.truth.Truth.assertThat 61 import kotlinx.coroutines.ExperimentalCoroutinesApi 62 import kotlinx.coroutines.flow.flowOf 63 import kotlinx.coroutines.launch 64 import kotlinx.coroutines.test.UnconfinedTestDispatcher 65 import kotlinx.coroutines.test.runTest 66 import org.junit.After 67 import org.junit.Assert.assertThrows 68 import org.junit.Before 69 import org.junit.Test 70 import org.junit.runner.RunWith 71 import org.mockito.ArgumentMatchers.anyFloat 72 import org.mockito.Mock 73 import org.mockito.Mockito.times 74 import org.mockito.Mockito.verify 75 import org.mockito.Mockito.`when` 76 import org.mockito.MockitoAnnotations 77 78 @ExperimentalCoroutinesApi 79 @RunWith(AndroidTestingRunner::class) 80 @TestableLooper.RunWithLooper(setAsMainLooper = true) 81 @SmallTest 82 class GlanceableHubContainerControllerTest : SysuiTestCase() { 83 private val kosmos: Kosmos = <lambda>null84 testKosmos().apply { 85 // UnconfinedTestDispatcher makes testing simpler due to CommunalInteractor flows using 86 // SharedFlow 87 testDispatcher = UnconfinedTestDispatcher() 88 } 89 90 @Mock private lateinit var communalViewModel: CommunalViewModel 91 @Mock private lateinit var powerManager: PowerManager 92 @Mock private lateinit var touchMonitor: TouchMonitor 93 @Mock private lateinit var communalColors: CommunalColors 94 @Mock private lateinit var communalContent: CommunalContent 95 private lateinit var ambientTouchComponentFactory: AmbientTouchComponent.Factory 96 97 private lateinit var parentView: FrameLayout 98 private lateinit var containerView: View 99 private lateinit var testableLooper: TestableLooper 100 101 private lateinit var communalRepository: FakeCommunalSceneRepository 102 private lateinit var underTest: GlanceableHubContainerController 103 104 @Before setUpnull105 fun setUp() { 106 MockitoAnnotations.initMocks(this) 107 108 communalRepository = kosmos.fakeCommunalSceneRepository 109 110 ambientTouchComponentFactory = 111 object : AmbientTouchComponent.Factory { 112 override fun create( 113 lifecycleOwner: LifecycleOwner, 114 touchHandlers: Set<TouchHandler> 115 ): AmbientTouchComponent = 116 object : AmbientTouchComponent { 117 override fun getTouchMonitor(): TouchMonitor = touchMonitor 118 } 119 } 120 121 with(kosmos) { 122 underTest = 123 GlanceableHubContainerController( 124 communalInteractor, 125 communalViewModel, 126 keyguardInteractor, 127 shadeInteractor, 128 powerManager, 129 communalColors, 130 ambientTouchComponentFactory, 131 communalContent, 132 kosmos.sceneDataSourceDelegator, 133 kosmos.notificationStackScrollLayoutController 134 ) 135 } 136 testableLooper = TestableLooper.get(this) 137 138 overrideResource(R.dimen.communal_right_edge_swipe_region_width, RIGHT_SWIPE_REGION_WIDTH) 139 overrideResource(R.dimen.communal_top_edge_swipe_region_height, TOP_SWIPE_REGION_WIDTH) 140 overrideResource( 141 R.dimen.communal_bottom_edge_swipe_region_height, 142 BOTTOM_SWIPE_REGION_WIDTH 143 ) 144 145 // Make communal available so that communalInteractor.desiredScene accurately reflects 146 // scene changes instead of just returning Blank. 147 mSetFlagsRule.enableFlags(Flags.FLAG_COMMUNAL_HUB) 148 with(kosmos.testScope) { 149 launch { kosmos.setCommunalAvailable(true) } 150 testScheduler.runCurrent() 151 } 152 153 initAndAttachContainerView() 154 } 155 156 @After tearDownnull157 fun tearDown() { 158 ViewUtils.detachView(parentView) 159 } 160 161 @Test initView_calledTwice_throwsExceptionnull162 fun initView_calledTwice_throwsException() = 163 with(kosmos) { 164 testScope.runTest { 165 underTest = 166 GlanceableHubContainerController( 167 communalInteractor, 168 communalViewModel, 169 keyguardInteractor, 170 shadeInteractor, 171 powerManager, 172 communalColors, 173 ambientTouchComponentFactory, 174 communalContent, 175 kosmos.sceneDataSourceDelegator, 176 kosmos.notificationStackScrollLayoutController 177 ) 178 179 // First call succeeds. 180 underTest.initView(context) 181 182 // Second call throws. 183 assertThrows(RuntimeException::class.java) { underTest.initView(context) } 184 } 185 } 186 187 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 188 @Test onTouchEvent_communalClosed_doesNotInterceptnull189 fun onTouchEvent_communalClosed_doesNotIntercept() = 190 with(kosmos) { 191 testScope.runTest { 192 // Communal is closed. 193 goToScene(CommunalScenes.Blank) 194 195 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse() 196 } 197 } 198 199 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 200 @Test onTouchEvent_openGesture_interceptsTouchesnull201 fun onTouchEvent_openGesture_interceptsTouches() = 202 with(kosmos) { 203 testScope.runTest { 204 // Communal is closed. 205 goToScene(CommunalScenes.Blank) 206 207 // Initial touch down is intercepted, and so are touches outside of the region, 208 // until an 209 // up event is received. 210 assertThat(underTest.onTouchEvent(DOWN_IN_RIGHT_SWIPE_REGION_EVENT)).isTrue() 211 assertThat(underTest.onTouchEvent(MOVE_EVENT)).isTrue() 212 assertThat(underTest.onTouchEvent(UP_EVENT)).isTrue() 213 assertThat(underTest.onTouchEvent(MOVE_EVENT)).isFalse() 214 } 215 } 216 217 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 218 @Test onTouchEvent_communalTransitioning_interceptsTouchesnull219 fun onTouchEvent_communalTransitioning_interceptsTouches() = 220 with(kosmos) { 221 testScope.runTest { 222 // Communal is opening. 223 communalRepository.setTransitionState( 224 flowOf( 225 ObservableTransitionState.Transition( 226 fromScene = CommunalScenes.Blank, 227 toScene = CommunalScenes.Communal, 228 currentScene = flowOf(CommunalScenes.Blank), 229 progress = flowOf(0.5f), 230 isInitiatedByUserInput = true, 231 isUserInputOngoing = flowOf(true) 232 ) 233 ) 234 ) 235 testableLooper.processAllMessages() 236 237 // Touch events are intercepted. 238 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isTrue() 239 // User activity sent to PowerManager. 240 verify(powerManager).userActivity(any(), any(), any()) 241 } 242 } 243 244 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 245 @Test onTouchEvent_communalOpen_interceptsTouchesnull246 fun onTouchEvent_communalOpen_interceptsTouches() = 247 with(kosmos) { 248 testScope.runTest { 249 // Communal is open. 250 goToScene(CommunalScenes.Communal) 251 252 // Touch events are intercepted. 253 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isTrue() 254 // User activity sent to PowerManager. 255 verify(powerManager).userActivity(any(), any(), any()) 256 } 257 } 258 259 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 260 @Test onTouchEvent_communalAndBouncerShowing_doesNotInterceptnull261 fun onTouchEvent_communalAndBouncerShowing_doesNotIntercept() = 262 with(kosmos) { 263 testScope.runTest { 264 // Communal is open. 265 goToScene(CommunalScenes.Communal) 266 267 // Bouncer is visible. 268 fakeKeyguardBouncerRepository.setPrimaryShow(true) 269 testableLooper.processAllMessages() 270 271 // Touch events are not intercepted. 272 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse() 273 // User activity is not sent to PowerManager. 274 verify(powerManager, times(0)).userActivity(any(), any(), any()) 275 } 276 } 277 278 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 279 @Test onTouchEvent_communalAndShadeShowing_doesNotInterceptnull280 fun onTouchEvent_communalAndShadeShowing_doesNotIntercept() = 281 with(kosmos) { 282 testScope.runTest { 283 // Communal is open. 284 goToScene(CommunalScenes.Communal) 285 286 // Shade shows up. 287 shadeTestUtil.setQsExpansion(1.0f) 288 testableLooper.processAllMessages() 289 290 // Touch events are not intercepted. 291 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse() 292 } 293 } 294 295 @DisableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) 296 @Test onTouchEvent_containerViewDisposed_doesNotInterceptnull297 fun onTouchEvent_containerViewDisposed_doesNotIntercept() = 298 with(kosmos) { 299 testScope.runTest { 300 // Communal is open. 301 goToScene(CommunalScenes.Communal) 302 303 // Touch events are intercepted. 304 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isTrue() 305 306 // Container view disposed. 307 underTest.disposeView() 308 309 // Touch events are not intercepted. 310 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse() 311 } 312 } 313 314 @Test lifecycle_initializedAfterConstructionnull315 fun lifecycle_initializedAfterConstruction() = 316 with(kosmos) { 317 val underTest = 318 GlanceableHubContainerController( 319 communalInteractor, 320 communalViewModel, 321 keyguardInteractor, 322 shadeInteractor, 323 powerManager, 324 communalColors, 325 ambientTouchComponentFactory, 326 communalContent, 327 kosmos.sceneDataSourceDelegator, 328 kosmos.notificationStackScrollLayoutController, 329 ) 330 331 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.INITIALIZED) 332 } 333 334 @Test lifecycle_createdAfterViewCreatednull335 fun lifecycle_createdAfterViewCreated() = 336 with(kosmos) { 337 val underTest = 338 GlanceableHubContainerController( 339 communalInteractor, 340 communalViewModel, 341 keyguardInteractor, 342 shadeInteractor, 343 powerManager, 344 communalColors, 345 ambientTouchComponentFactory, 346 communalContent, 347 kosmos.sceneDataSourceDelegator, 348 kosmos.notificationStackScrollLayoutController, 349 ) 350 351 // Only initView without attaching a view as we don't want the flows to start collecting 352 // yet. 353 underTest.initView(View(context)) 354 355 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.CREATED) 356 } 357 358 @Test lifecycle_startedAfterFlowsUpdatenull359 fun lifecycle_startedAfterFlowsUpdate() { 360 // Flows start collecting due to test setup, causing the state to advance to STARTED. 361 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) 362 } 363 364 @Test lifecycle_resumedAfterCommunalShowsnull365 fun lifecycle_resumedAfterCommunalShows() { 366 // Communal is open. 367 goToScene(CommunalScenes.Communal) 368 369 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED) 370 } 371 372 @Test lifecycle_startedAfterCommunalClosesnull373 fun lifecycle_startedAfterCommunalCloses() = 374 with(kosmos) { 375 testScope.runTest { 376 // Communal is open. 377 goToScene(CommunalScenes.Communal) 378 379 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.RESUMED) 380 381 // Communal closes. 382 goToScene(CommunalScenes.Blank) 383 384 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) 385 } 386 } 387 388 @Test lifecycle_startedAfterPrimaryBouncerShowsnull389 fun lifecycle_startedAfterPrimaryBouncerShows() = 390 with(kosmos) { 391 testScope.runTest { 392 // Communal is open. 393 goToScene(CommunalScenes.Communal) 394 395 // Bouncer is visible. 396 fakeKeyguardBouncerRepository.setPrimaryShow(true) 397 testableLooper.processAllMessages() 398 399 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) 400 } 401 } 402 403 @Test lifecycle_startedAfterAlternateBouncerShowsnull404 fun lifecycle_startedAfterAlternateBouncerShows() = 405 with(kosmos) { 406 testScope.runTest { 407 // Communal is open. 408 goToScene(CommunalScenes.Communal) 409 410 // Bouncer is visible. 411 fakeKeyguardBouncerRepository.setAlternateVisible(true) 412 testableLooper.processAllMessages() 413 414 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) 415 } 416 } 417 418 @Test lifecycle_createdAfterDisposeViewnull419 fun lifecycle_createdAfterDisposeView() { 420 // Container view disposed. 421 underTest.disposeView() 422 423 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.CREATED) 424 } 425 426 @Test lifecycle_startedAfterShadeShowsnull427 fun lifecycle_startedAfterShadeShows() = 428 with(kosmos) { 429 testScope.runTest { 430 // Communal is open. 431 goToScene(CommunalScenes.Communal) 432 433 // Shade shows up. 434 shadeTestUtil.setQsExpansion(1.0f) 435 testableLooper.processAllMessages() 436 437 assertThat(underTest.lifecycle.currentState).isEqualTo(Lifecycle.State.STARTED) 438 } 439 } 440 441 @Test editMode_communalAvailablenull442 fun editMode_communalAvailable() = 443 with(kosmos) { 444 testScope.runTest { 445 val available by collectLastValue(underTest.communalAvailable()) 446 setCommunalAvailable(false) 447 448 assertThat(available).isFalse() 449 communalInteractor.setEditModeOpen(true) 450 assertThat(available).isTrue() 451 } 452 } 453 454 @Test gestureExclusionZone_setAfterInitnull455 fun gestureExclusionZone_setAfterInit() = 456 with(kosmos) { 457 testScope.runTest { 458 goToScene(CommunalScenes.Communal) 459 460 assertThat(containerView.systemGestureExclusionRects) 461 .containsExactly( 462 Rect( 463 /* left */ 0, 464 /* top */ TOP_SWIPE_REGION_WIDTH, 465 /* right */ CONTAINER_WIDTH, 466 /* bottom */ CONTAINER_HEIGHT - BOTTOM_SWIPE_REGION_WIDTH 467 ) 468 ) 469 } 470 } 471 472 @Test gestureExclusionZone_unsetWhenShadeOpennull473 fun gestureExclusionZone_unsetWhenShadeOpen() = 474 with(kosmos) { 475 testScope.runTest { 476 goToScene(CommunalScenes.Communal) 477 478 // Shade shows up. 479 shadeTestUtil.setQsExpansion(1.0f) 480 testableLooper.processAllMessages() 481 482 // Exclusion rects are unset. 483 assertThat(containerView.systemGestureExclusionRects).isEmpty() 484 } 485 } 486 487 @Test gestureExclusionZone_unsetWhenBouncerOpennull488 fun gestureExclusionZone_unsetWhenBouncerOpen() = 489 with(kosmos) { 490 testScope.runTest { 491 goToScene(CommunalScenes.Communal) 492 493 // Bouncer is visible. 494 fakeKeyguardBouncerRepository.setPrimaryShow(true) 495 testableLooper.processAllMessages() 496 497 // Exclusion rects are unset. 498 assertThat(containerView.systemGestureExclusionRects).isEmpty() 499 } 500 } 501 502 @Test gestureExclusionZone_unsetWhenHubClosednull503 fun gestureExclusionZone_unsetWhenHubClosed() = 504 with(kosmos) { 505 testScope.runTest { 506 goToScene(CommunalScenes.Communal) 507 508 // Exclusion rect is set. 509 assertThat(containerView.systemGestureExclusionRects).hasSize(1) 510 511 // Leave the hub. 512 goToScene(CommunalScenes.Blank) 513 514 // Exclusion rect is unset. 515 assertThat(containerView.systemGestureExclusionRects).isEmpty() 516 } 517 } 518 519 @Test 520 @EnableFlags(FLAG_GLANCEABLE_HUB_FULLSCREEN_SWIPE) fullScreenSwipeGesture_doNotProcessTouchesInNotificationStacknull521 fun fullScreenSwipeGesture_doNotProcessTouchesInNotificationStack() = 522 with(kosmos) { 523 testScope.runTest { 524 // Communal is closed. 525 goToScene(CommunalScenes.Blank) 526 `when`( 527 notificationStackScrollLayoutController.isBelowLastNotification( 528 anyFloat(), 529 anyFloat() 530 ) 531 ) 532 .thenReturn(false) 533 assertThat(underTest.onTouchEvent(DOWN_EVENT)).isFalse() 534 } 535 } 536 initAndAttachContainerViewnull537 private fun initAndAttachContainerView() { 538 containerView = View(context) 539 540 parentView = FrameLayout(context) 541 542 parentView.addView(underTest.initView(containerView)) 543 544 // Attach the view so that flows start collecting. 545 ViewUtils.attachView(parentView, CONTAINER_WIDTH, CONTAINER_HEIGHT) 546 // Attaching is async so processAllMessages is required for view.repeatWhenAttached to run. 547 testableLooper.processAllMessages() 548 } 549 goToScenenull550 private fun goToScene(scene: SceneKey) { 551 communalRepository.changeScene(scene) 552 testableLooper.processAllMessages() 553 } 554 555 companion object { 556 private const val CONTAINER_WIDTH = 100 557 private const val CONTAINER_HEIGHT = 100 558 private const val RIGHT_SWIPE_REGION_WIDTH = 20 559 private const val TOP_SWIPE_REGION_WIDTH = 12 560 private const val BOTTOM_SWIPE_REGION_WIDTH = 14 561 562 /** 563 * A touch down event right in the middle of the screen, to avoid being in any of the swipe 564 * regions. 565 */ 566 private val DOWN_EVENT = 567 MotionEvent.obtain( 568 0L, 569 0L, 570 MotionEvent.ACTION_DOWN, 571 CONTAINER_WIDTH.toFloat() / 2, 572 CONTAINER_HEIGHT.toFloat() / 2, 573 0 574 ) 575 private val DOWN_IN_RIGHT_SWIPE_REGION_EVENT = 576 MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_DOWN, CONTAINER_WIDTH.toFloat(), 0f, 0) 577 private val MOVE_EVENT = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_MOVE, 0f, 0f, 0) 578 private val UP_EVENT = MotionEvent.obtain(0L, 0L, MotionEvent.ACTION_UP, 0f, 0f, 0) 579 } 580 } 581