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