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.flag.junit.SetFlagsRule 27 import android.view.IWindowManager 28 import android.view.WindowManager 29 import android.view.WindowManagerGlobal 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.common.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.bubbles.Bubbles.SysuiProxy 40 import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix 41 import com.android.wm.shell.common.FloatingContentCoordinator 42 import com.android.wm.shell.common.ShellExecutor 43 import com.android.wm.shell.shared.animation.PhysicsAnimatorTestUtils 44 import com.android.wm.shell.taskview.TaskView 45 import com.android.wm.shell.taskview.TaskViewTaskController 46 import com.google.common.truth.Truth.assertThat 47 import com.google.common.util.concurrent.MoreExecutors.directExecutor 48 import org.junit.After 49 import org.junit.Before 50 import org.junit.Rule 51 import org.junit.Test 52 import org.junit.runner.RunWith 53 import org.mockito.kotlin.mock 54 import android.platform.test.annotations.DisableFlags 55 import android.platform.test.annotations.EnableFlags 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 iconFactory: BubbleIconFactory 70 private lateinit var expandedViewManager: FakeBubbleExpandedViewManager 71 private lateinit var bubbleStackView: BubbleStackView 72 private lateinit var shellExecutor: ShellExecutor 73 private lateinit var windowManager: IWindowManager 74 private lateinit var bubbleTaskViewFactory: BubbleTaskViewFactory 75 private lateinit var bubbleData: BubbleData 76 private lateinit var bubbleStackViewManager: FakeBubbleStackViewManager 77 private var sysuiProxy = mock<SysuiProxy>() 78 79 @Before 80 fun setUp() { 81 PhysicsAnimatorTestUtils.prepareForTest() 82 // Disable protolog tool when running the tests from studio 83 ProtoLog.REQUIRE_PROTOLOGTOOL = false 84 windowManager = WindowManagerGlobal.getWindowManagerService()!! 85 shellExecutor = TestShellExecutor() 86 val 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 bubbleData = 99 BubbleData( 100 context, 101 BubbleLogger(UiEventLoggerFake()), 102 positioner, 103 BubbleEducationController(context), 104 shellExecutor 105 ) 106 bubbleStackViewManager = FakeBubbleStackViewManager() 107 expandedViewManager = FakeBubbleExpandedViewManager() 108 bubbleTaskViewFactory = FakeBubbleTaskViewFactory() 109 bubbleStackView = 110 BubbleStackView( 111 context, 112 bubbleStackViewManager, 113 positioner, 114 bubbleData, 115 null, 116 FloatingContentCoordinator(), 117 { sysuiProxy }, 118 shellExecutor 119 ) 120 121 context 122 .getSharedPreferences(context.packageName, Context.MODE_PRIVATE) 123 .edit() 124 .putBoolean(StackEducationView.PREF_STACK_EDUCATION, true) 125 .apply() 126 } 127 128 @After 129 fun tearDown() { 130 PhysicsAnimatorTestUtils.tearDown() 131 } 132 133 @Test 134 fun addBubble() { 135 val bubble = createAndInflateBubble() 136 InstrumentationRegistry.getInstrumentation().runOnMainSync { 137 bubbleStackView.addBubble(bubble) 138 } 139 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 140 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 141 } 142 143 @Test 144 fun tapBubbleToExpand() { 145 val bubble = createAndInflateBubble() 146 147 InstrumentationRegistry.getInstrumentation().runOnMainSync { 148 bubbleStackView.addBubble(bubble) 149 } 150 151 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 152 assertThat(bubbleStackView.bubbleCount).isEqualTo(1) 153 var lastUpdate: BubbleData.Update? = null 154 val semaphore = Semaphore(0) 155 val listener = 156 BubbleData.Listener { update -> 157 lastUpdate = update 158 semaphore.release() 159 } 160 bubbleData.setListener(listener) 161 162 InstrumentationRegistry.getInstrumentation().runOnMainSync { 163 bubble.iconView!!.performClick() 164 // we're checking the expanded state in BubbleData because that's the source of truth. 165 // This will eventually propagate an update back to the stack view, but setting the 166 // entire pipeline is outside the scope of a unit test. 167 assertThat(bubbleData.isExpanded).isTrue() 168 } 169 170 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 171 assertThat(lastUpdate).isNotNull() 172 assertThat(lastUpdate!!.expandedChanged).isTrue() 173 assertThat(lastUpdate!!.expanded).isTrue() 174 } 175 176 @Test 177 fun tapDifferentBubble_shouldReorder() { 178 val bubble1 = createAndInflateChatBubble(key = "bubble1") 179 val bubble2 = createAndInflateChatBubble(key = "bubble2") 180 InstrumentationRegistry.getInstrumentation().runOnMainSync { 181 bubbleStackView.addBubble(bubble1) 182 bubbleStackView.addBubble(bubble2) 183 } 184 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 185 186 assertThat(bubbleStackView.bubbleCount).isEqualTo(2) 187 assertThat(bubbleData.bubbles).hasSize(2) 188 assertThat(bubbleData.selectedBubble).isEqualTo(bubble2) 189 assertThat(bubble2.iconView).isNotNull() 190 191 var lastUpdate: BubbleData.Update? = null 192 val semaphore = Semaphore(0) 193 val listener = 194 BubbleData.Listener { update -> 195 lastUpdate = update 196 semaphore.release() 197 } 198 bubbleData.setListener(listener) 199 200 InstrumentationRegistry.getInstrumentation().runOnMainSync { 201 bubble2.iconView!!.performClick() 202 assertThat(bubbleData.isExpanded).isTrue() 203 204 bubbleStackView.setSelectedBubble(bubble2) 205 bubbleStackView.isExpanded = true 206 } 207 208 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 209 assertThat(lastUpdate!!.expanded).isTrue() 210 assertThat(lastUpdate!!.bubbles.map { it.key }) 211 .containsExactly("bubble2", "bubble1") 212 .inOrder() 213 214 // wait for idle to allow the animation to start 215 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 216 // wait for the expansion animation to complete before interacting with the bubbles 217 PhysicsAnimatorTestUtils.blockUntilAnimationsEnd( 218 AnimatableScaleMatrix.SCALE_X, AnimatableScaleMatrix.SCALE_Y) 219 220 // tap on bubble1 to select it 221 InstrumentationRegistry.getInstrumentation().runOnMainSync { 222 bubble1.iconView!!.performClick() 223 } 224 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 225 assertThat(bubbleData.selectedBubble).isEqualTo(bubble1) 226 227 // tap on bubble1 again to collapse the stack 228 InstrumentationRegistry.getInstrumentation().runOnMainSync { 229 // we have to set the selected bubble in the stack view manually because we don't have a 230 // listener wired up. 231 bubbleStackView.setSelectedBubble(bubble1) 232 bubble1.iconView!!.performClick() 233 } 234 235 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 236 assertThat(bubbleData.selectedBubble).isEqualTo(bubble1) 237 assertThat(bubbleData.isExpanded).isFalse() 238 assertThat(lastUpdate!!.orderChanged).isTrue() 239 assertThat(lastUpdate!!.bubbles.map { it.key }) 240 .containsExactly("bubble1", "bubble2") 241 .inOrder() 242 } 243 244 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 245 @Test 246 fun testCreateStackView_noOverflowContents_noOverflow() { 247 bubbleStackView = 248 BubbleStackView( 249 context, 250 bubbleStackViewManager, 251 positioner, 252 bubbleData, 253 null, 254 FloatingContentCoordinator(), 255 { sysuiProxy }, 256 shellExecutor 257 ) 258 259 assertThat(bubbleData.overflowBubbles).isEmpty() 260 val bubbleOverflow = bubbleData.overflow 261 // Overflow shouldn't be attached 262 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1) 263 } 264 265 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 266 @Test 267 fun testCreateStackView_hasOverflowContents_hasOverflow() { 268 // Add a bubble to the overflow 269 val bubble1 = createAndInflateChatBubble(key = "bubble1") 270 bubbleData.notificationEntryUpdated(bubble1, false, false) 271 bubbleData.dismissBubbleWithKey(bubble1.key, Bubbles.DISMISS_USER_GESTURE) 272 assertThat(bubbleData.overflowBubbles).isNotEmpty() 273 274 bubbleStackView = 275 BubbleStackView( 276 context, 277 bubbleStackViewManager, 278 positioner, 279 bubbleData, 280 null, 281 FloatingContentCoordinator(), 282 { sysuiProxy }, 283 shellExecutor 284 ) 285 val bubbleOverflow = bubbleData.overflow 286 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 287 } 288 289 @DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 290 @Test 291 fun testCreateStackView_noOverflowContents_hasOverflow() { 292 bubbleStackView = 293 BubbleStackView( 294 context, 295 bubbleStackViewManager, 296 positioner, 297 bubbleData, 298 null, 299 FloatingContentCoordinator(), 300 { sysuiProxy }, 301 shellExecutor 302 ) 303 304 assertThat(bubbleData.overflowBubbles).isEmpty() 305 val bubbleOverflow = bubbleData.overflow 306 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 307 } 308 309 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 310 @Test 311 fun showOverflow_true() { 312 InstrumentationRegistry.getInstrumentation().runOnMainSync { 313 bubbleStackView.showOverflow(true) 314 } 315 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 316 317 val bubbleOverflow = bubbleData.overflow 318 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 319 } 320 321 @EnableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 322 @Test 323 fun showOverflow_false() { 324 InstrumentationRegistry.getInstrumentation().runOnMainSync { 325 bubbleStackView.showOverflow(true) 326 } 327 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 328 val bubbleOverflow = bubbleData.overflow 329 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 330 331 InstrumentationRegistry.getInstrumentation().runOnMainSync { 332 bubbleStackView.showOverflow(false) 333 } 334 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 335 336 // The overflow should've been removed 337 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isEqualTo(-1) 338 } 339 340 @DisableFlags(Flags.FLAG_ENABLE_OPTIONAL_BUBBLE_OVERFLOW) 341 @Test 342 fun showOverflow_ignored() { 343 InstrumentationRegistry.getInstrumentation().runOnMainSync { 344 bubbleStackView.showOverflow(false) 345 } 346 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 347 348 // showOverflow should've been ignored, so the overflow would be attached 349 val bubbleOverflow = bubbleData.overflow 350 assertThat(bubbleStackView.getBubbleIndex(bubbleOverflow)).isGreaterThan(-1) 351 } 352 353 private fun createAndInflateChatBubble(key: String): Bubble { 354 val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button) 355 val shortcutInfo = ShortcutInfo.Builder(context, "fakeId").setIcon(icon).build() 356 val bubble = 357 Bubble( 358 key, 359 shortcutInfo, 360 /* desiredHeight= */ 6, 361 Resources.ID_NULL, 362 "title", 363 /* taskId= */ 0, 364 "locus", 365 /* isDismissable= */ true, 366 directExecutor() 367 ) {} 368 inflateBubble(bubble) 369 return bubble 370 } 371 372 private fun createAndInflateBubble(): Bubble { 373 val intent = Intent(Intent.ACTION_VIEW).setPackage(context.packageName) 374 val icon = Icon.createWithResource(context.resources, R.drawable.bubble_ic_overflow_button) 375 val bubble = Bubble.createAppBubble(intent, UserHandle(1), icon, directExecutor()) 376 inflateBubble(bubble) 377 return bubble 378 } 379 380 private fun inflateBubble(bubble: Bubble) { 381 bubble.setInflateSynchronously(true) 382 bubbleData.notificationEntryUpdated(bubble, true, false) 383 384 val semaphore = Semaphore(0) 385 val callback: BubbleViewInfoTask.Callback = 386 BubbleViewInfoTask.Callback { semaphore.release() } 387 bubble.inflate( 388 callback, 389 context, 390 expandedViewManager, 391 bubbleTaskViewFactory, 392 positioner, 393 bubbleStackView, 394 null, 395 iconFactory, 396 false 397 ) 398 399 assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() 400 assertThat(bubble.isInflated).isTrue() 401 } 402 403 private class FakeBubbleStackViewManager : BubbleStackViewManager { 404 405 override fun onAllBubblesAnimatedOut() {} 406 407 override fun updateWindowFlagsForBackpress(interceptBack: Boolean) {} 408 409 override fun checkNotificationPanelExpandedState(callback: Consumer<Boolean>) {} 410 411 override fun hideCurrentInputMethod() {} 412 } 413 414 private class TestShellExecutor : ShellExecutor { 415 416 override fun execute(runnable: Runnable) { 417 runnable.run() 418 } 419 420 override fun executeDelayed(r: Runnable, delayMillis: Long) { 421 r.run() 422 } 423 424 override fun removeCallbacks(r: Runnable?) {} 425 426 override fun hasCallback(r: Runnable): Boolean = false 427 } 428 429 private inner class FakeBubbleTaskViewFactory : BubbleTaskViewFactory { 430 override fun create(): BubbleTaskView { 431 val taskViewTaskController = mock<TaskViewTaskController>() 432 val taskView = TaskView(context, taskViewTaskController) 433 return BubbleTaskView(taskView, shellExecutor) 434 } 435 } 436 437 private inner class FakeBubbleExpandedViewManager : BubbleExpandedViewManager { 438 439 override val overflowBubbles: List<Bubble> 440 get() = emptyList() 441 442 override fun setOverflowListener(listener: BubbleData.Listener) {} 443 444 override fun collapseStack() {} 445 446 override fun updateWindowFlagsForBackpress(intercept: Boolean) {} 447 448 override fun promoteBubbleFromOverflow(bubble: Bubble) {} 449 450 override fun removeBubble(key: String, reason: Int) {} 451 452 override fun dismissBubble(bubble: Bubble, reason: Int) {} 453 454 override fun setAppBubbleTaskId(key: String, taskId: Int) {} 455 456 override fun isStackExpanded(): Boolean = false 457 458 override fun isShowingAsBubbleBar(): Boolean = false 459 460 override fun hideCurrentInputMethod() {} 461 } 462 } 463