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.wm.shell.bubbles 18 19 import android.content.Context 20 import android.content.Intent 21 import android.content.pm.ShortcutInfo 22 import android.content.res.Resources 23 import android.graphics.Color 24 import android.graphics.drawable.Icon 25 import android.os.UserHandle 26 import android.platform.test.annotations.DisableFlags 27 import android.platform.test.annotations.EnableFlags 28 import android.platform.test.flag.junit.SetFlagsRule 29 import android.view.WindowManager 30 import androidx.test.core.app.ApplicationProvider 31 import androidx.test.ext.junit.runners.AndroidJUnit4 32 import androidx.test.filters.SmallTest 33 import androidx.test.platform.app.InstrumentationRegistry 34 import com.android.internal.logging.testing.UiEventLoggerFake 35 import com.android.internal.protolog.ProtoLog 36 import com.android.launcher3.icons.BubbleIconFactory 37 import com.android.wm.shell.Flags 38 import com.android.wm.shell.R 39 import com.android.wm.shell.TestShellExecutor 40 import com.android.wm.shell.bubbles.Bubbles.SysuiProxy 41 import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix 42 import com.android.wm.shell.common.FloatingContentCoordinator 43 import com.android.wm.shell.shared.animation.PhysicsAnimatorTestUtils 44 import com.google.common.truth.Truth.assertThat 45 import com.google.common.util.concurrent.MoreExecutors.directExecutor 46 import org.junit.After 47 import org.junit.Before 48 import org.junit.Rule 49 import org.junit.Test 50 import org.junit.runner.RunWith 51 import org.mockito.Mockito 52 import org.mockito.kotlin.any 53 import org.mockito.kotlin.mock 54 import org.mockito.kotlin.never 55 import org.mockito.kotlin.verify 56 import java.util.concurrent.Semaphore 57 import java.util.concurrent.TimeUnit 58 import java.util.function.Consumer 59 60 /** Unit tests for [BubbleStackView]. */ 61 @SmallTest 62 @RunWith(AndroidJUnit4::class) 63 class BubbleStackViewTest { 64 65 @get:Rule val setFlagsRule = SetFlagsRule() 66 67 private val context = ApplicationProvider.getApplicationContext<Context>() 68 private lateinit var positioner: BubblePositioner 69 private lateinit var bubbleLogger: BubbleLogger 70 private lateinit var iconFactory: BubbleIconFactory 71 private lateinit var expandedViewManager: FakeBubbleExpandedViewManager 72 private lateinit var bubbleStackView: BubbleStackView 73 private lateinit var shellExecutor: TestShellExecutor 74 private lateinit var windowManager: WindowManager 75 private lateinit var bubbleTaskViewFactory: BubbleTaskViewFactory 76 private lateinit var bubbleData: BubbleData 77 private lateinit var bubbleStackViewManager: FakeBubbleStackViewManager 78 private var sysuiProxy = mock<SysuiProxy>() 79 80 @Before 81 fun setUp() { 82 PhysicsAnimatorTestUtils.prepareForTest() 83 // Disable protolog tool when running the tests from studio 84 ProtoLog.REQUIRE_PROTOLOGTOOL = false 85 shellExecutor = TestShellExecutor() 86 windowManager = context.getSystemService(WindowManager::class.java) 87 iconFactory = 88 BubbleIconFactory( 89 context, 90 context.resources.getDimensionPixelSize(R.dimen.bubble_size), 91 context.resources.getDimensionPixelSize(R.dimen.bubble_badge_size), 92 Color.BLACK, 93 context.resources.getDimensionPixelSize( 94 com.android.internal.R.dimen.importance_ring_stroke_width 95 ) 96 ) 97 positioner = BubblePositioner(context, windowManager) 98 bubbleLogger = BubbleLogger(UiEventLoggerFake()) 99 bubbleData = 100 BubbleData( 101 context, 102 bubbleLogger, 103 positioner, 104 BubbleEducationController(context), 105 shellExecutor, 106 shellExecutor 107 ) 108 bubbleStackViewManager = FakeBubbleStackViewManager() 109 expandedViewManager = FakeBubbleExpandedViewManager() 110 bubbleTaskViewFactory = FakeBubbleTaskViewFactory(context, shellExecutor) 111 bubbleStackView = 112 BubbleStackView( 113 context, 114 bubbleStackViewManager, 115 positioner, 116 bubbleData, 117 null, 118 FloatingContentCoordinator(), 119 { sysuiProxy }, 120 shellExecutor 121 ) 122 123 context 124 .getSharedPreferences(context.packageName, Context.MODE_PRIVATE) 125 .edit() 126 .putBoolean(StackEducationView.PREF_STACK_EDUCATION, true) 127 .apply() 128 } 129 130 @After 131 fun tearDown() { 132 PhysicsAnimatorTestUtils.tearDown() 133 } 134 135 @Test 136 fun addBubble() { 137 val bubble = createAndInflateBubble() 138 InstrumentationRegistry.getInstrumentation().runOnMainSync { 139 bubbleStackView.addBubble(bubble) 140 } 141 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 142 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 143 } 144 145 @Test 146 fun tapBubbleToExpand() { 147 val bubble = createAndInflateBubble() 148 149 InstrumentationRegistry.getInstrumentation().runOnMainSync { 150 bubbleStackView.addBubble(bubble) 151 } 152 153 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 154 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 155 var lastUpdate: BubbleData.Update? = null 156 val semaphore = Semaphore(0) 157 val listener = 158 BubbleData.Listener { update -> 159 lastUpdate = update 160 semaphore.release() 161 } 162 bubbleData.setListener(listener) 163 164 InstrumentationRegistry.getInstrumentation().runOnMainSync { 165 bubble.iconView!!.performClick() 166 // we're checking the expanded state in BubbleData because that's the source of truth. 167 // This will eventually propagate an update back to the stack view, but setting the 168 // entire pipeline is outside the scope of a unit test. 169 assertThat(bubbleData.isExpanded).isTrue() 170 shellExecutor.flushAll() 171 } 172 173 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 174 assertThat(lastUpdate).isNotNull() 175 assertThat(lastUpdate!!.expandedChanged).isTrue() 176 assertThat(lastUpdate!!.expanded).isTrue() 177 } 178 179 @Test 180 fun expandStack_imeHidden() { 181 val bubble = createAndInflateBubble() 182 183 InstrumentationRegistry.getInstrumentation().runOnMainSync { 184 bubbleStackView.addBubble(bubble) 185 } 186 187 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 188 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 189 190 positioner.setImeVisible(false, 0) 191 192 InstrumentationRegistry.getInstrumentation().runOnMainSync { 193 // simulate a request from the bubble data listener to expand the stack 194 bubbleStackView.isExpanded = true 195 verify(sysuiProxy).onStackExpandChanged(true) 196 shellExecutor.flushAll() 197 } 198 199 assertThat(bubbleStackViewManager.onImeHidden).isNull() 200 } 201 202 @Test 203 fun collapseStack_imeHidden() { 204 val bubble = createAndInflateBubble() 205 206 InstrumentationRegistry.getInstrumentation().runOnMainSync { 207 bubbleStackView.addBubble(bubble) 208 } 209 210 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 211 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 212 213 positioner.setImeVisible(false, 0) 214 215 InstrumentationRegistry.getInstrumentation().runOnMainSync { 216 // simulate a request from the bubble data listener to expand the stack 217 bubbleStackView.isExpanded = true 218 verify(sysuiProxy).onStackExpandChanged(true) 219 shellExecutor.flushAll() 220 } 221 222 assertThat(bubbleStackViewManager.onImeHidden).isNull() 223 224 InstrumentationRegistry.getInstrumentation().runOnMainSync { 225 // simulate a request from the bubble data listener to collapse the stack 226 bubbleStackView.isExpanded = false 227 verify(sysuiProxy).onStackExpandChanged(false) 228 shellExecutor.flushAll() 229 } 230 231 assertThat(bubbleStackViewManager.onImeHidden).isNull() 232 } 233 234 @Test 235 fun expandStack_waitsForIme() { 236 val bubble = createAndInflateBubble() 237 238 InstrumentationRegistry.getInstrumentation().runOnMainSync { 239 bubbleStackView.addBubble(bubble) 240 } 241 242 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 243 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 244 245 positioner.setImeVisible(true, 100) 246 247 InstrumentationRegistry.getInstrumentation().runOnMainSync { 248 // simulate a request from the bubble data listener to expand the stack 249 bubbleStackView.isExpanded = true 250 } 251 252 val onImeHidden = bubbleStackViewManager.onImeHidden 253 assertThat(onImeHidden).isNotNull() 254 verify(sysuiProxy, never()).onStackExpandChanged(any()) 255 positioner.setImeVisible(false, 0) 256 InstrumentationRegistry.getInstrumentation().runOnMainSync { 257 onImeHidden!!.run() 258 verify(sysuiProxy).onStackExpandChanged(true) 259 shellExecutor.flushAll() 260 } 261 } 262 263 @Test 264 fun collapseStack_waitsForIme() { 265 val bubble = createAndInflateBubble() 266 267 InstrumentationRegistry.getInstrumentation().runOnMainSync { 268 bubbleStackView.addBubble(bubble) 269 } 270 271 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 272 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 273 274 positioner.setImeVisible(true, 100) 275 276 InstrumentationRegistry.getInstrumentation().runOnMainSync { 277 // simulate a request from the bubble data listener to expand the stack 278 bubbleStackView.isExpanded = true 279 } 280 281 var onImeHidden = bubbleStackViewManager.onImeHidden 282 assertThat(onImeHidden).isNotNull() 283 verify(sysuiProxy, never()).onStackExpandChanged(any()) 284 positioner.setImeVisible(false, 0) 285 InstrumentationRegistry.getInstrumentation().runOnMainSync { 286 onImeHidden!!.run() 287 verify(sysuiProxy).onStackExpandChanged(true) 288 shellExecutor.flushAll() 289 } 290 291 bubbleStackViewManager.onImeHidden = null 292 positioner.setImeVisible(true, 100) 293 294 InstrumentationRegistry.getInstrumentation().runOnMainSync { 295 // simulate a request from the bubble data listener to collapse the stack 296 bubbleStackView.isExpanded = false 297 } 298 299 onImeHidden = bubbleStackViewManager.onImeHidden 300 assertThat(onImeHidden).isNotNull() 301 verify(sysuiProxy, never()).onStackExpandChanged(false) 302 positioner.setImeVisible(false, 0) 303 InstrumentationRegistry.getInstrumentation().runOnMainSync { 304 onImeHidden!!.run() 305 verify(sysuiProxy).onStackExpandChanged(false) 306 shellExecutor.flushAll() 307 } 308 } 309 310 @Test 311 fun tapDifferentBubble_shouldReorder() { 312 val bubble1 = createAndInflateChatBubble(key = "bubble1") 313 val bubble2 = createAndInflateChatBubble(key = "bubble2") 314 InstrumentationRegistry.getInstrumentation().runOnMainSync { 315 bubbleStackView.addBubble(bubble1) 316 bubbleStackView.addBubble(bubble2) 317 } 318 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 319 320 assertThat(bubbleStackView.bubbleCount).isEqualTo(2) 321 assertThat(bubbleData.bubbles).hasSize(2) 322 assertThat(bubbleData.selectedBubble).isEqualTo(bubble2) 323 assertThat(bubble2.iconView).isNotNull() 324 325 var lastUpdate: BubbleData.Update? = null 326 val semaphore = Semaphore(0) 327 val listener = 328 BubbleData.Listener { update -> 329 lastUpdate = update 330 semaphore.release() 331 } 332 bubbleData.setListener(listener) 333 334 InstrumentationRegistry.getInstrumentation().runOnMainSync { 335 bubble2.iconView!!.performClick() 336 assertThat(bubbleData.isExpanded).isTrue() 337 338 bubbleStackView.setSelectedBubble(bubble2) 339 bubbleStackView.isExpanded = true 340 shellExecutor.flushAll() 341 } 342 343 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 344 assertThat(lastUpdate!!.expanded).isTrue() 345 assertThat(lastUpdate!!.bubbles.map { it.key }) 346 .containsExactly("bubble2", "bubble1") 347 .inOrder() 348 349 // wait for idle to allow the animation to start 350 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 351 // wait for the expansion animation to complete before interacting with the bubbles 352 PhysicsAnimatorTestUtils.blockUntilAnimationsEnd( 353 AnimatableScaleMatrix.SCALE_X, AnimatableScaleMatrix.SCALE_Y) 354 355 // tap on bubble1 to select it 356 InstrumentationRegistry.getInstrumentation().runOnMainSync { 357 bubble1.iconView!!.performClick() 358 shellExecutor.flushAll() 359 } 360 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 361 assertThat(bubbleData.selectedBubble).isEqualTo(bubble1) 362 363 // tap on bubble1 again to collapse the stack 364 InstrumentationRegistry.getInstrumentation().runOnMainSync { 365 // we have to set the selected bubble in the stack view manually because we don't have a 366 // listener wired up. 367 bubbleStackView.setSelectedBubble(bubble1) 368 bubble1.iconView!!.performClick() 369 shellExecutor.flushAll() 370 } 371 372 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 373 assertThat(bubbleData.selectedBubble).isEqualTo(bubble1) 374 assertThat(bubbleData.isExpanded).isFalse() 375 assertThat(lastUpdate!!.orderChanged).isTrue() 376 assertThat(lastUpdate!!.bubbles.map { it.key }) 377 .containsExactly("bubble1", "bubble2") 378 .inOrder() 379 } 380 381 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 382 @Test 383 fun testCreateStackView_noOverflowContents_noOverflow() { 384 bubbleStackView = 385 BubbleStackView( 386 context, 387 bubbleStackViewManager, 388 positioner, 389 bubbleData, 390 null, 391 FloatingContentCoordinator(), 392 { sysuiProxy }, 393 shellExecutor 394 ) 395 396 assertThat(bubbleData.overflowBubbles).isEmpty() 397 val bubbleOverflow = bubbleData.overflow 398 // Overflow shouldn't be attached 399 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1) 400 } 401 402 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 403 @Test 404 fun testCreateStackView_hasOverflowContents_hasOverflow() { 405 // Add a bubble to the overflow 406 val bubble1 = createAndInflateChatBubble(key = "bubble1") 407 bubbleData.notificationEntryUpdated(bubble1, false, false) 408 bubbleData.dismissBubbleWithKey(bubble1.key, Bubbles.DISMISS_USER_GESTURE) 409 assertThat(bubbleData.overflowBubbles).isNotEmpty() 410 411 bubbleStackView = 412 BubbleStackView( 413 context, 414 bubbleStackViewManager, 415 positioner, 416 bubbleData, 417 null, 418 FloatingContentCoordinator(), 419 { sysuiProxy }, 420 shellExecutor 421 ) 422 val bubbleOverflow = bubbleData.overflow 423 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 424 } 425 426 @DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 427 @Test 428 fun testCreateStackView_noOverflowContents_hasOverflow() { 429 bubbleStackView = 430 BubbleStackView( 431 context, 432 bubbleStackViewManager, 433 positioner, 434 bubbleData, 435 null, 436 FloatingContentCoordinator(), 437 { sysuiProxy }, 438 shellExecutor 439 ) 440 441 assertThat(bubbleData.overflowBubbles).isEmpty() 442 val bubbleOverflow = bubbleData.overflow 443 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 444 } 445 446 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 447 @Test 448 fun showOverflow_true() { 449 InstrumentationRegistry.getInstrumentation().runOnMainSync { 450 bubbleStackView.showOverflow(true) 451 } 452 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 453 454 val bubbleOverflow = bubbleData.overflow 455 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 456 } 457 458 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 459 @Test 460 fun showOverflow_false() { 461 InstrumentationRegistry.getInstrumentation().runOnMainSync { 462 bubbleStackView.showOverflow(true) 463 } 464 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 465 val bubbleOverflow = bubbleData.overflow 466 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 467 468 InstrumentationRegistry.getInstrumentation().runOnMainSync { 469 bubbleStackView.showOverflow(false) 470 } 471 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 472 473 // The overflow should've been removed 474 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1) 475 } 476 477 @DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 478 @Test 479 fun showOverflow_ignored() { 480 InstrumentationRegistry.getInstrumentation().runOnMainSync { 481 bubbleStackView.showOverflow(false) 482 } 483 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 484 485 // showOverflow should've been ignored, so the overflow would be attached 486 val bubbleOverflow = bubbleData.overflow 487 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 488 } 489 490 @Test 491 fun removeFromWindow_stopMonitoringSwipeUpGesture() { 492 bubbleStackView = Mockito.spy(bubbleStackView) 493 InstrumentationRegistry.getInstrumentation().runOnMainSync { 494 // No way to add to window in the test environment right now so just pretend 495 bubbleStackView.onDetachedFromWindow() 496 } 497 verify(bubbleStackView).stopMonitoringSwipeUpGesture() 498 } 499 500 private fun createAndInflateChatBubble(key: String): Bubble { 501 val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button) 502 val shortcutInfo = ShortcutInfo.Builder(context, "fakeId").setIcon(icon).build() 503 val bubble = 504 Bubble( 505 key, 506 shortcutInfo, 507 /* desiredHeight= */ 6, 508 Resources.ID_NULL, 509 "title", 510 /* taskId= */ 0, 511 "locus", 512 /* isDismissable= */ true, 513 directExecutor(), 514 directExecutor() 515 ) {} 516 inflateBubble(bubble) 517 return bubble 518 } 519 520 private fun createAndInflateBubble(): Bubble { 521 val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName) 522 val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button) 523 val bubble = 524 Bubble.createAppBubble(intent, UserHandle(1), icon, directExecutor(), directExecutor()) 525 inflateBubble(bubble) 526 return bubble 527 } 528 529 private fun inflateBubble(bubble: Bubble) { 530 bubble.setInflateSynchronously(true) 531 bubbleData.notificationEntryUpdated(bubble, true, false) 532 533 val semaphore = Semaphore(0) 534 val callback: BubbleViewInfoTask.Callback = 535 BubbleViewInfoTask.Callback { semaphore.release() } 536 bubble.inflate( 537 callback, 538 context, 539 expandedViewManager, 540 bubbleTaskViewFactory, 541 positioner, 542 bubbleLogger, 543 bubbleStackView, 544 null, 545 iconFactory, 546 false 547 ) 548 549 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 550 assertThat(bubble.isInflated).isTrue() 551 } 552 553 private class FakeBubbleStackViewManager : BubbleStackViewManager { 554 var onImeHidden: Runnable? = null 555 556 override fun onAllBubblesAnimatedOut() {} 557 558 override fun updateWindowFlagsForBackpress(interceptBack: Boolean) {} 559 560 override fun checkNotificationPanelExpandedState(callback: Consumer<Boolean>) {} 561 562 override fun hideCurrentInputMethod(onImeHidden: Runnable?) { 563 this.onImeHidden = onImeHidden 564 } 565 } 566 } 567