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