• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download

<lambda>null1 package com.android.systemui.statusbar.notification.stack
2 
3 import android.annotation.DimenRes
4 import android.content.pm.PackageManager
5 import android.platform.test.annotations.EnableFlags
6 import android.platform.test.flag.junit.FlagsParameterization
7 import android.widget.FrameLayout
8 import androidx.test.filters.SmallTest
9 import com.android.keyguard.BouncerPanelExpansionCalculator.aboutToShowBouncerProgress
10 import com.android.systemui.SysuiTestCase
11 import com.android.systemui.animation.ShadeInterpolation.getContentAlpha
12 import com.android.systemui.dump.DumpManager
13 import com.android.systemui.flags.DisableSceneContainer
14 import com.android.systemui.flags.EnableSceneContainer
15 import com.android.systemui.flags.FeatureFlags
16 import com.android.systemui.flags.FeatureFlagsClassic
17 import com.android.systemui.flags.andSceneContainer
18 import com.android.systemui.res.R
19 import com.android.systemui.scene.shared.flag.SceneContainerFlag
20 import com.android.systemui.shade.transition.LargeScreenShadeInterpolator
21 import com.android.systemui.statusbar.NotificationShelf
22 import com.android.systemui.statusbar.StatusBarState
23 import com.android.systemui.statusbar.chips.notification.shared.StatusBarNotifChips
24 import com.android.systemui.statusbar.notification.RoundableState
25 import com.android.systemui.statusbar.notification.collection.EntryAdapter
26 import com.android.systemui.statusbar.notification.collection.NotificationEntry
27 import com.android.systemui.statusbar.notification.data.repository.HeadsUpRepository
28 import com.android.systemui.statusbar.notification.emptyshade.ui.view.EmptyShadeView
29 import com.android.systemui.statusbar.notification.footer.ui.view.FooterView
30 import com.android.systemui.statusbar.notification.footer.ui.view.FooterView.FooterViewState
31 import com.android.systemui.statusbar.notification.headsup.AvalancheController
32 import com.android.systemui.statusbar.notification.headsup.HeadsUpAnimator
33 import com.android.systemui.statusbar.notification.headsup.NotificationsHunSharedAnimationValues
34 import com.android.systemui.statusbar.notification.row.ExpandableNotificationRow
35 import com.android.systemui.statusbar.notification.row.ExpandableView
36 import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager
37 import com.android.systemui.statusbar.ui.fakeSystemBarUtilsProxy
38 import com.android.systemui.testKosmos
39 import com.google.common.truth.Expect
40 import com.google.common.truth.Truth.assertThat
41 import kotlin.math.roundToInt
42 import org.junit.Assume
43 import org.junit.Before
44 import org.junit.Rule
45 import org.junit.Test
46 import org.junit.runner.RunWith
47 import org.mockito.kotlin.any
48 import org.mockito.kotlin.eq
49 import org.mockito.kotlin.mock
50 import org.mockito.kotlin.verify
51 import org.mockito.kotlin.whenever
52 import platform.test.runner.parameterized.ParameterizedAndroidJunit4
53 import platform.test.runner.parameterized.Parameters
54 
55 @SmallTest
56 @RunWith(ParameterizedAndroidJunit4::class)
57 class StackScrollAlgorithmTest(flags: FlagsParameterization) : SysuiTestCase() {
58 
59     @JvmField @Rule var expect: Expect = Expect.create()
60 
61     private val kosmos = testKosmos()
62 
63     private val largeScreenShadeInterpolator = mock<LargeScreenShadeInterpolator>()
64     private val avalancheController = mock<AvalancheController>()
65 
66     private val hostView = FrameLayout(context)
67     private lateinit var headsUpAnimator: HeadsUpAnimator
68     private lateinit var stackScrollAlgorithm: StackScrollAlgorithm
69     private val notificationRow = mock<ExpandableNotificationRow>()
70     private val notificationEntry = mock<NotificationEntry>()
71     private val notificationEntryAdapter = mock<EntryAdapter>()
72     private val dumpManager = mock<DumpManager>()
73     private val mStatusBarKeyguardViewManager = mock<StatusBarKeyguardViewManager>()
74     private val notificationShelf = mock<NotificationShelf>()
75     private val headsUpRepository = mock<HeadsUpRepository>()
76     private val emptyShadeView =
77         EmptyShadeView(context, /* attrs= */ null).apply {
78             layout(/* l= */ 0, /* t= */ 0, /* r= */ 100, /* b= */ 100)
79         }
80     private val footerView = FooterView(context, /* attrs= */ null)
81     private val ambientState =
82         AmbientState(
83             context,
84             dumpManager,
85             /* sectionProvider */ { _, _ -> false },
86             /* bypassController */ { false },
87             mStatusBarKeyguardViewManager,
88             largeScreenShadeInterpolator,
89             headsUpRepository,
90             avalancheController,
91         )
92 
93     private val testableResources = mContext.getOrCreateTestableResources()
94     private val featureFlags = mock<FeatureFlagsClassic>()
95     private val maxPanelHeight =
96         mContext.resources.displayMetrics.heightPixels -
97             px(R.dimen.notification_panel_margin_top) -
98             px(R.dimen.notification_panel_margin_bottom)
99 
100     private fun px(@DimenRes id: Int): Float =
101         testableResources.resources.getDimensionPixelSize(id).toFloat()
102 
103     private val notifSectionDividerGap = px(R.dimen.notification_section_divider_height)
104     private val scrimPadding = px(R.dimen.notification_side_paddings)
105     private val baseZ by lazy { ambientState.baseZHeight }
106     private val headsUpZ = px(R.dimen.heads_up_pinned_elevation)
107     private val bigGap = notifSectionDividerGap
108     private val smallGap = px(R.dimen.notification_section_divider_height_lockscreen)
109 
110     companion object {
111         @JvmStatic
112         @Parameters(name = "{0}")
113         fun getParams(): List<FlagsParameterization> {
114             return FlagsParameterization.allCombinationsOf(
115                     NotificationsHunSharedAnimationValues.FLAG_NAME
116                 )
117                 .andSceneContainer()
118         }
119     }
120 
121     init {
122         mSetFlagsRule.setFlagsParameterization(flags)
123     }
124 
125     @Before
126     fun setUp() {
127         Assume.assumeFalse(isTv())
128         mDependency.injectTestDependency(FeatureFlags::class.java, featureFlags)
129         whenever(notificationShelf.viewState).thenReturn(ExpandableViewState())
130         whenever(notificationRow.key).thenReturn("key")
131         whenever(notificationRow.viewState).thenReturn(ExpandableViewState())
132         whenever(notificationRow.entryLegacy).thenReturn(notificationEntry)
133         whenever(notificationRow.entryAdapter).thenReturn(notificationEntryAdapter)
134         whenever(notificationRow.roundableState)
135             .thenReturn(RoundableState(notificationRow, notificationRow, 0f))
136         ambientState.isSmallScreen = true
137 
138         hostView.addView(notificationRow)
139 
140         if (NotificationsHunSharedAnimationValues.isEnabled) {
141             headsUpAnimator = HeadsUpAnimator(context, kosmos.fakeSystemBarUtilsProxy)
142         }
143         stackScrollAlgorithm =
144             StackScrollAlgorithm(
145                 context,
146                 hostView,
147                 if (::headsUpAnimator.isInitialized) headsUpAnimator else null,
148             )
149     }
150 
151     private fun isTv(): Boolean {
152         return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
153     }
154 
155     @Test
156     @EnableSceneContainer
157     fun resetViewStates_childPositionedAtStackTop() {
158         val stackTop = 100f
159         ambientState.stackTop = stackTop
160 
161         stackScrollAlgorithm.resetViewStates(ambientState, 0)
162 
163         assertThat(notificationRow.viewState.yTranslation).isEqualTo(stackTop)
164     }
165 
166     @Test
167     @EnableSceneContainer
168     fun resetViewStates_defaultHun_yTranslationIsHeadsUpTop() {
169         val headsUpTop = 200f
170         ambientState.headsUpTop = headsUpTop
171 
172         whenever(notificationRow.isPinned).thenReturn(true)
173         whenever(notificationRow.isHeadsUp).thenReturn(true)
174 
175         resetViewStates_hunYTranslationIs(headsUpTop)
176     }
177 
178     @Test
179     @EnableSceneContainer
180     fun resetViewStates_defaultHun_hasShadow() {
181         val headsUpTop = 200f
182         ambientState.headsUpTop = headsUpTop
183 
184         whenever(notificationRow.isPinned).thenReturn(true)
185         whenever(notificationRow.isHeadsUp).thenReturn(true)
186 
187         stackScrollAlgorithm.resetViewStates(ambientState, 0)
188 
189         assertThat(notificationRow.viewState.zTranslation).isGreaterThan(baseZ)
190     }
191 
192     @Test
193     @DisableSceneContainer
194     fun resetViewStates_defaultHunWhenShadeIsOpening_yTranslationIsInset() {
195         whenever(notificationRow.isPinned).thenReturn(true)
196         whenever(notificationRow.isHeadsUp).thenReturn(true)
197 
198         // scroll the panel over the HUN inset
199         ambientState.stackY = stackScrollAlgorithm.mHeadsUpInset + bigGap
200 
201         // the HUN translation should be the panel scroll position + the scrim padding
202         resetViewStates_hunYTranslationIs(ambientState.stackY + scrimPadding)
203     }
204 
205     @Test
206     @DisableSceneContainer
207     fun resetViewStates_defaultHun_yTranslationIsInset() {
208         whenever(notificationRow.isPinned).thenReturn(true)
209         whenever(notificationRow.isHeadsUp).thenReturn(true)
210         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
211     }
212 
213     @Test
214     @DisableSceneContainer
215     fun resetViewStates_defaultHunWithStackMargin_changesHunYTranslation() {
216         whenever(notificationRow.isPinned).thenReturn(true)
217         whenever(notificationRow.isHeadsUp).thenReturn(true)
218         resetViewStates_stackMargin_changesHunYTranslation()
219     }
220 
221     @Test
222     @EnableSceneContainer
223     fun resetViewStates_defaultHunInShade_stackTopEqualsHunTop_hunHasFullHeight() {
224         // Given: headsUpTop == stackTop -> haven't scrolled the stack yet
225         val headsUpTop = 150f
226         val collapsedHeight = 100
227         val intrinsicHeight = 300
228         fakeHunInShade(
229             headsUpTop = headsUpTop,
230             stackTop = headsUpTop,
231             collapsedHeight = collapsedHeight,
232             intrinsicHeight = intrinsicHeight,
233         )
234 
235         // When
236         stackScrollAlgorithm.resetViewStates(ambientState, 0)
237 
238         // Then: HUN is at the headsUpTop
239         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTop)
240         // And: HUN is not elevated
241         assertThat(notificationRow.viewState.zTranslation).isEqualTo(baseZ)
242         // And: HUN has its full height
243         assertThat(notificationRow.viewState.height).isEqualTo(intrinsicHeight)
244     }
245 
246     @Test
247     @EnableSceneContainer
248     fun resetViewStates_defaultHunInShade_stackTopGreaterThanHeadsUpTop_hunClampedToHeadsUpTop() {
249         // Given: headsUpTop < stackTop -> scrolled the stack a little bit
250         val stackTop = -25f
251         val headsUpTop = 150f
252         val collapsedHeight = 100
253         val intrinsicHeight = 300
254         fakeHunInShade(
255             headsUpTop = headsUpTop,
256             stackTop = stackTop,
257             collapsedHeight = collapsedHeight,
258             intrinsicHeight = intrinsicHeight,
259         )
260 
261         // When
262         stackScrollAlgorithm.resetViewStates(ambientState, 0)
263 
264         // Then: HUN is translated to the headsUpTop
265         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTop)
266         // And: HUN is not elevated
267         assertThat(notificationRow.viewState.zTranslation).isEqualTo(baseZ)
268         // And: HUN is clipped to the available space
269         // newTranslation = max(150, -25)
270         // distToReal = 150 - (-25)
271         // height = max(300 - 175, 100)
272         assertThat(notificationRow.viewState.height).isEqualTo(125)
273     }
274 
275     @Test
276     @EnableSceneContainer
277     fun resetViewStates_defaultHunInShade_stackOverscrolledHun_hunClampedToHeadsUpTop() {
278         // Given: headsUpTop << stackTop -> stack has fully overscrolled the HUN
279         val stackTop = -500f
280         val headsUpTop = 150f
281         val collapsedHeight = 100
282         val intrinsicHeight = 300
283         fakeHunInShade(
284             headsUpTop = headsUpTop,
285             stackTop = stackTop,
286             collapsedHeight = collapsedHeight,
287             intrinsicHeight = intrinsicHeight,
288         )
289 
290         // When
291         stackScrollAlgorithm.resetViewStates(ambientState, 0)
292 
293         // Then: HUN is translated to the headsUpTop
294         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTop)
295         // And: HUN fully elevated to baseZ + headsUpZ
296         assertThat(notificationRow.viewState.zTranslation).isEqualTo(baseZ + headsUpZ)
297         // And: HUN is clipped to its collapsed height
298         assertThat(notificationRow.viewState.height).isEqualTo(collapsedHeight)
299     }
300 
301     @Test
302     @EnableSceneContainer
303     fun resetViewStates_defaultHun_showingQS_hunTranslatedToHeadsUpTop() {
304         // Given: the shade is open and scrolled to the bottom to show the QuickSettings
305         val headsUpTop = 2000f
306         val intrinsicHunHeight = 300
307         fakeHunInShade(
308             headsUpTop = headsUpTop,
309             stackTop = 2600f, // stack scrolled below the screen
310             stackCutoff = 4000f,
311             collapsedHeight = 100,
312             intrinsicHeight = intrinsicHunHeight,
313         )
314         ambientState.qsExpansionFraction = 1.0f
315         whenever(notificationRow.isAboveShelf).thenReturn(true)
316 
317         // When
318         stackScrollAlgorithm.resetViewStates(ambientState, 0)
319 
320         // Then: HUN is translated to the headsUpTop
321         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTop)
322         // And: HUN is elevated to baseZ + headsUpZ
323         assertThat(notificationRow.viewState.zTranslation).isEqualTo(baseZ + headsUpZ)
324         // And: HUN maintained its full height
325         assertThat(notificationRow.viewState.height).isEqualTo(intrinsicHunHeight)
326     }
327 
328     @Test
329     @EnableSceneContainer
330     fun updateZTranslationForHunInStack_fullOverlap_hunHasFullElevation() {
331         // Given: the overlap equals to the top content padding
332         val contentTop = 280f
333         val contentTopPadding = 20f
334         val viewState =
335             ExpandableViewState().apply {
336                 height = 100
337                 yTranslation = 200f
338             }
339 
340         // When
341         stackScrollAlgorithm.updateZTranslationForHunInStack(
342             /* scrollingContentTop = */ contentTop,
343             /* scrollingContentTopPadding */ contentTopPadding,
344             /* baseZ = */ 0f,
345             /* viewState = */ viewState,
346         )
347 
348         // Then: HUN is fully elevated to baseZ + headsUpZ
349         assertThat(viewState.zTranslation).isEqualTo(headsUpZ)
350     }
351 
352     @Test
353     @EnableSceneContainer
354     fun updateZTranslationForHunInStack_someOverlap_hunIsPartlyElevated() {
355         // Given: the overlap is bigger than zero, but less than the top content padding
356         val contentTop = 290f
357         val contentTopPadding = 20f
358         val viewState =
359             ExpandableViewState().apply {
360                 height = 100
361                 yTranslation = 200f
362             }
363 
364         // When
365         stackScrollAlgorithm.updateZTranslationForHunInStack(
366             /* scrollingContentTop = */ contentTop,
367             /* scrollingContentTopPadding */ contentTopPadding,
368             /* baseZ = */ 0f,
369             /* viewState = */ viewState,
370         )
371 
372         // Then: HUN is partly elevated
373         assertThat(viewState.zTranslation).apply {
374             isGreaterThan(0f)
375             isLessThan(headsUpZ)
376         }
377     }
378 
379     @Test
380     @EnableSceneContainer
381     fun updateZTranslationForHunInStack_noOverlap_hunIsNotElevated() {
382         // Given: no overlap between the content and the HUN
383         val contentTop = 300f
384         val contentTopPadding = 20f
385         val viewState =
386             ExpandableViewState().apply {
387                 height = 100
388                 yTranslation = 200f
389             }
390 
391         // When
392         stackScrollAlgorithm.updateZTranslationForHunInStack(
393             /* scrollingContentTop = */ contentTop,
394             /* scrollingContentTopPadding */ contentTopPadding,
395             /* baseZ = */ 0f,
396             /* viewState = */ viewState,
397         )
398 
399         // Then: HUN is not elevated
400         assertThat(viewState.zTranslation).isEqualTo(0f)
401     }
402 
403     @Test
404     @DisableSceneContainer
405     fun resetViewStates_defaultHun_showingQS_hunTranslatedToMax() {
406         // Given: the shade is open and scrolled to the bottom to show the QuickSettings
407         val maxHunTranslation = 2000f
408         ambientState.maxHeadsUpTranslation = maxHunTranslation
409         ambientState.setLayoutMinHeight(2500) // Mock the height of shade
410         ambientState.stackY = 2500f // Scroll over the max translation
411         stackScrollAlgorithm.setIsExpanded(true) // Mark the shade open
412         whenever(notificationRow.mustStayOnScreen()).thenReturn(true)
413         whenever(notificationRow.isHeadsUp).thenReturn(true)
414         whenever(notificationRow.isAboveShelf).thenReturn(true)
415 
416         resetViewStates_hunYTranslationIs(maxHunTranslation)
417     }
418 
419     @Test
420     @DisableSceneContainer
421     fun resetViewStates_hunAnimatingAway_showingQS_hunTranslatedToBottomOfScreen() {
422         // Given: the shade is open and scrolled to the bottom to show the QuickSettings
423         val bottomOfScreen = 2600f
424         val maxHunTranslation = 2000f
425         ambientState.maxHeadsUpTranslation = maxHunTranslation
426         ambientState.setLayoutMinHeight(2500) // Mock the height of shade
427         ambientState.stackY = 2500f // Scroll over the max translation
428         stackScrollAlgorithm.setIsExpanded(true) // Mark the shade open
429         if (NotificationsHunSharedAnimationValues.isEnabled) {
430             headsUpAnimator.headsUpAppearHeightBottom = bottomOfScreen.toInt()
431         } else {
432             stackScrollAlgorithm.setHeadsUpAppearHeightBottom(bottomOfScreen.toInt())
433         }
434         whenever(notificationRow.mustStayOnScreen()).thenReturn(true)
435         whenever(notificationRow.isHeadsUp).thenReturn(true)
436         whenever(notificationRow.isAboveShelf).thenReturn(true)
437         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
438 
439         resetViewStates_hunYTranslationIs(
440             expected = bottomOfScreen + stackScrollAlgorithm.mHeadsUpAppearStartAboveScreen
441         )
442     }
443 
444     @Test
445     fun resetViewStates_hunAnimatingAway_hunTranslatedToTopOfScreen() {
446         val topMargin = 100f
447         ambientState.maxHeadsUpTranslation = 2000f
448         ambientState.stackTopMargin = topMargin.toInt()
449         if (NotificationsHunSharedAnimationValues.isEnabled) {
450             headsUpAnimator.stackTopMargin = topMargin.toInt()
451         }
452         whenever(notificationRow.intrinsicHeight).thenReturn(100)
453         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
454 
455         resetViewStates_hunYTranslationIs(
456             expected = -topMargin - stackScrollAlgorithm.mHeadsUpAppearStartAboveScreen
457         )
458     }
459 
460     @Test
461     @EnableFlags(NotificationsHunSharedAnimationValues.FLAG_NAME, StatusBarNotifChips.FLAG_NAME)
462     fun resetViewStates_hunAnimatingAway_noStatusBarChip_hunTranslatedToTopOfScreen() {
463         val topMargin = 100f
464         ambientState.maxHeadsUpTranslation = 2000f
465         ambientState.stackTopMargin = topMargin.toInt()
466         headsUpAnimator?.stackTopMargin = topMargin.toInt()
467         whenever(notificationRow.intrinsicHeight).thenReturn(100)
468 
469         val statusBarHeight = 432
470         kosmos.fakeSystemBarUtilsProxy.fakeStatusBarHeight = statusBarHeight
471         headsUpAnimator!!.updateResources(context)
472 
473         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
474         whenever(notificationRow.hasStatusBarChipDuringHeadsUpAnimation()).thenReturn(false)
475 
476         resetViewStates_hunYTranslationIs(
477             expected = -topMargin - stackScrollAlgorithm.mHeadsUpAppearStartAboveScreen
478         )
479     }
480 
481     @Test
482     @EnableFlags(NotificationsHunSharedAnimationValues.FLAG_NAME, StatusBarNotifChips.FLAG_NAME)
483     fun resetViewStates_hunAnimatingAway_withStatusBarChip_hunTranslatedToBottomOfStatusBar() {
484         val topMargin = 100f
485         ambientState.maxHeadsUpTranslation = 2000f
486         ambientState.stackTopMargin = topMargin.toInt()
487         headsUpAnimator?.stackTopMargin = topMargin.toInt()
488         whenever(notificationRow.intrinsicHeight).thenReturn(100)
489 
490         val statusBarHeight = 432
491         kosmos.fakeSystemBarUtilsProxy.fakeStatusBarHeight = statusBarHeight
492         headsUpAnimator!!.updateResources(context)
493 
494         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
495         whenever(notificationRow.hasStatusBarChipDuringHeadsUpAnimation()).thenReturn(true)
496 
497         resetViewStates_hunYTranslationIs(expected = statusBarHeight - topMargin)
498     }
499 
500     @Test
501     fun resetViewStates_hunAnimatingAway_bottomNotClipped() {
502         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
503 
504         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
505 
506         assertThat(notificationRow.viewState.clipBottomAmount).isEqualTo(0)
507     }
508 
509     @Test
510     @DisableSceneContainer
511     fun resetViewStates_hunAnimatingAwayWhileDozing_yTranslationIsInset() {
512         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
513 
514         ambientState.isDozing = true
515 
516         resetViewStates_hunYTranslationIs(stackScrollAlgorithm.mHeadsUpInset)
517     }
518 
519     @Test
520     @DisableSceneContainer
521     fun resetViewStates_hunAnimatingAwayWhileDozing_hasStackMargin_changesHunYTranslation() {
522         whenever(notificationRow.isHeadsUpAnimatingAway).thenReturn(true)
523 
524         ambientState.isDozing = true
525 
526         resetViewStates_stackMargin_changesHunYTranslation()
527     }
528 
529     @Test
530     fun resetViewStates_hunsOverlapping_bottomHunClipped() {
531         val topHun = mockExpandableNotificationRow()
532         whenever(topHun.key).thenReturn("key")
533         whenever(topHun.entryAdapter).thenReturn(notificationEntryAdapter)
534         val bottomHun = mockExpandableNotificationRow()
535         whenever(bottomHun.key).thenReturn("key")
536         whenever(bottomHun.entryAdapter).thenReturn(notificationEntryAdapter)
537         whenever(topHun.isHeadsUp).thenReturn(true)
538         whenever(topHun.isPinned).thenReturn(true)
539         whenever(bottomHun.isHeadsUp).thenReturn(true)
540         whenever(bottomHun.isPinned).thenReturn(true)
541 
542         resetViewStates_hunsOverlapping_bottomHunClipped(topHun, bottomHun)
543     }
544 
545     @Test
546     @EnableSceneContainer
547     fun resetViewStates_emptyShadeView_isCenteredVertically_withSceneContainer() {
548         stackScrollAlgorithm.initView(context)
549         hostView.removeAllViews()
550         hostView.addView(emptyShadeView)
551         ambientState.layoutMaxHeight = maxPanelHeight.toInt()
552 
553         val stackTop = 200f
554         val stackBottom = 2000f
555         val stackHeight = stackBottom - stackTop
556         ambientState.stackTop = stackTop
557         ambientState.stackCutoff = stackBottom
558 
559         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
560 
561         val centeredY = stackTop + stackHeight / 2f - emptyShadeView.height / 2f
562         assertThat(emptyShadeView.viewState.yTranslation).isEqualTo(centeredY)
563     }
564 
565     @Test
566     @DisableSceneContainer
567     fun resetViewStates_emptyShadeView_isCenteredVertically() {
568         stackScrollAlgorithm.initView(context)
569         hostView.removeAllViews()
570         hostView.addView(emptyShadeView)
571         ambientState.layoutMaxHeight = maxPanelHeight.toInt()
572 
573         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
574 
575         val marginBottom =
576             context.resources.getDimensionPixelSize(R.dimen.notification_panel_margin_bottom)
577         val fullHeight = ambientState.layoutMaxHeight + marginBottom - ambientState.stackY
578         val centeredY = ambientState.stackY + fullHeight / 2f - emptyShadeView.height / 2f
579         assertThat(emptyShadeView.viewState.yTranslation).isEqualTo(centeredY)
580     }
581 
582     @Test
583     fun resetViewStates_hunGoingToShade_viewBecomesOpaque() {
584         whenever(notificationRow.isAboveShelf).thenReturn(true)
585         ambientState.isShadeExpanded = true
586         ambientState.trackedHeadsUpRow = notificationRow
587         stackScrollAlgorithm.initView(context)
588 
589         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
590 
591         assertThat(notificationRow.viewState.alpha).isEqualTo(1f)
592     }
593 
594     @Test
595     fun resetViewStates_expansionChanging_notificationBecomesTransparent() {
596         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
597         resetViewStates_expansionChanging_notificationAlphaUpdated(
598             expansionFraction = 0.25f,
599             expectedAlpha = 0.0f,
600         )
601     }
602 
603     @Test
604     fun resetViewStates_expansionChangingWhileBouncerInTransit_viewBecomesTransparent() {
605         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(true)
606         resetViewStates_expansionChanging_notificationAlphaUpdated(
607             expansionFraction = 0.85f,
608             expectedAlpha = 0.0f,
609         )
610     }
611 
612     @Test
613     fun resetViewStates_expansionChanging_notificationAlphaUpdated() {
614         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
615         resetViewStates_expansionChanging_notificationAlphaUpdated(
616             expansionFraction = 0.6f,
617             expectedAlpha = getContentAlpha(0.6f),
618         )
619     }
620 
621     @Test
622     fun resetViewStates_largeScreen_expansionChanging_alphaUpdated_largeScreenValue() {
623         val expansionFraction = 0.6f
624         val surfaceAlpha = 123f
625         ambientState.isSmallScreen = false
626         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(false)
627         whenever(largeScreenShadeInterpolator.getNotificationContentAlpha(expansionFraction))
628             .thenReturn(surfaceAlpha)
629 
630         resetViewStates_expansionChanging_notificationAlphaUpdated(
631             expansionFraction = expansionFraction,
632             expectedAlpha = surfaceAlpha,
633         )
634     }
635 
636     @Test
637     fun expansionChanging_largeScreen_bouncerInTransit_alphaUpdated_bouncerValues() {
638         ambientState.isSmallScreen = false
639         whenever(mStatusBarKeyguardViewManager.isPrimaryBouncerInTransit).thenReturn(true)
640         resetViewStates_expansionChanging_notificationAlphaUpdated(
641             expansionFraction = 0.95f,
642             expectedAlpha = aboutToShowBouncerProgress(0.95f),
643         )
644     }
645 
646     @Test
647     fun resetViewStates_expansionChanging_shelfUpdated() {
648         ambientState.shelf = notificationShelf
649         ambientState.isExpansionChanging = true
650         ambientState.expansionFraction = 0.6f
651         stackScrollAlgorithm.initView(context)
652 
653         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
654 
655         verify(notificationShelf)
656             .updateState(/* algorithmState= */ any(), /* ambientState= */ eq(ambientState))
657     }
658 
659     @Test
660     fun resetViewStates_isOnKeyguard_viewBecomesTransparent() {
661         ambientState.fakeShowingStackOnLockscreen()
662         ambientState.hideAmount = 0.25f
663         whenever(notificationRow.isHeadsUpState).thenReturn(true)
664 
665         stackScrollAlgorithm.initView(context)
666 
667         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
668 
669         assertThat(notificationRow.viewState.alpha).isEqualTo(1f - ambientState.hideAmount)
670     }
671 
672     @Test
673     fun resetViewStates_isOnKeyguard_emptyShadeViewBecomesTransparent() {
674         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
675         ambientState.fractionToShade = 0.25f
676         stackScrollAlgorithm.initView(context)
677         hostView.removeAllViews()
678         hostView.addView(emptyShadeView)
679 
680         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
681 
682         val expected = getContentAlpha(ambientState.fractionToShade)
683         assertThat(emptyShadeView.viewState.alpha).isEqualTo(expected)
684     }
685 
686     @Test
687     fun resetViewStates_shadeCollapsed_emptyShadeViewBecomesTransparent() {
688         ambientState.expansionFraction = 0f
689         stackScrollAlgorithm.initView(context)
690         hostView.removeAllViews()
691         hostView.addView(emptyShadeView)
692 
693         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
694 
695         assertThat(emptyShadeView.viewState.alpha).isEqualTo(0f)
696     }
697 
698     @Test
699     fun resetViewStates_isOnKeyguard_emptyShadeViewBecomesOpaque() {
700         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
701         ambientState.fractionToShade = 0.25f
702         stackScrollAlgorithm.initView(context)
703         hostView.removeAllViews()
704         hostView.addView(emptyShadeView)
705 
706         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
707 
708         val expected = getContentAlpha(ambientState.fractionToShade)
709         assertThat(emptyShadeView.viewState.alpha).isEqualTo(expected)
710     }
711 
712     @Test
713     fun resetViewStates_hiddenShelf_allRowsBecomesTransparent() {
714         hostView.removeAllViews()
715         val row1 = mockExpandableNotificationRow()
716         hostView.addView(row1)
717         val row2 = mockExpandableNotificationRow()
718         hostView.addView(row2)
719 
720         whenever(row1.isHeadsUpState).thenReturn(true)
721         whenever(row2.isHeadsUpState).thenReturn(false)
722 
723         ambientState.fakeShowingStackOnLockscreen()
724         ambientState.hideAmount = 0.25f
725         ambientState.dozeAmount = 0.33f
726         notificationShelf.viewState.hidden = true
727         ambientState.shelf = notificationShelf
728         stackScrollAlgorithm.initView(context)
729 
730         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
731 
732         assertThat(row1.viewState.alpha).isEqualTo(1f - ambientState.hideAmount)
733         assertThat(row2.viewState.alpha).isEqualTo(1f - ambientState.dozeAmount)
734     }
735 
736     @Test
737     fun resetViewStates_hiddenShelf_shelfAlphaDoesNotChange() {
738         val expected = notificationShelf.viewState.alpha
739         notificationShelf.viewState.hidden = true
740         ambientState.shelf = notificationShelf
741         stackScrollAlgorithm.initView(context)
742 
743         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
744 
745         assertThat(notificationShelf.viewState.alpha).isEqualTo(expected)
746     }
747 
748     @Test
749     fun resetViewStates_shelfTopLessThanViewTop_hidesView() {
750         notificationRow.viewState.yTranslation = 10f
751         notificationShelf.viewState.yTranslation = 0.9f
752         notificationShelf.viewState.hidden = false
753         ambientState.shelf = notificationShelf
754         stackScrollAlgorithm.initView(context)
755 
756         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
757 
758         assertThat(notificationRow.viewState.alpha).isEqualTo(0f)
759     }
760 
761     @Test
762     fun resetViewStates_shelfTopGreaterOrEqualThanViewTop_viewAlphaDoesNotChange() {
763         val expected = notificationRow.viewState.alpha
764         notificationRow.viewState.yTranslation = 10f
765         notificationShelf.viewState.yTranslation = 10f
766         notificationShelf.viewState.hidden = false
767         ambientState.shelf = notificationShelf
768         stackScrollAlgorithm.initView(context)
769 
770         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
771 
772         assertThat(notificationRow.viewState.alpha).isEqualTo(expected)
773     }
774 
775     @Test
776     @DisableSceneContainer
777     fun resetViewStates_noSpaceForFooter_footerHidden() {
778         ambientState.isShadeExpanded = true
779         ambientState.stackEndHeight = 0f // no space for the footer in the stack
780         hostView.addView(footerView)
781 
782         stackScrollAlgorithm.resetViewStates(ambientState, 0)
783 
784         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
785     }
786 
787     @Test
788     @EnableSceneContainer
789     fun resetViewStates_noSpaceForFooter_footerHidden_withSceneContainer() {
790         ambientState.isShadeExpanded = true
791         ambientState.stackTop = 0f
792         ambientState.stackCutoff = 100f
793         val footerView = mockFooterView(height = 200) // no space for the footer in the stack
794         hostView.addView(footerView)
795 
796         stackScrollAlgorithm.resetViewStates(ambientState, 0)
797 
798         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
799     }
800 
801     @Test
802     fun resetViewStates_clearAllInProgress_hasNonClearableRow_footerVisible() {
803         whenever(notificationRow.canViewBeCleared()).thenReturn(false)
804         ambientState.isClearAllInProgress = true
805         ambientState.isShadeExpanded = true
806         ambientState.stackEndHeight = maxPanelHeight // plenty space for the footer in the stack
807         hostView.addView(footerView)
808 
809         stackScrollAlgorithm.resetViewStates(ambientState, 0)
810 
811         assertThat(footerView.viewState.hidden).isFalse()
812         assertThat((footerView.viewState as FooterViewState).hideContent).isFalse()
813     }
814 
815     @Test
816     fun resetViewStates_clearAllInProgress_allRowsClearable_footerHidden() {
817         whenever(notificationRow.canViewBeCleared()).thenReturn(true)
818         ambientState.isClearAllInProgress = true
819         ambientState.isShadeExpanded = true
820         ambientState.stackEndHeight = maxPanelHeight // plenty space for the footer in the stack
821         hostView.addView(footerView)
822 
823         stackScrollAlgorithm.resetViewStates(ambientState, 0)
824 
825         assertThat((footerView.viewState as FooterViewState).hideContent).isTrue()
826     }
827 
828     @Test
829     fun getGapForLocation_onLockscreen_returnsSmallGap() {
830         val gap =
831             stackScrollAlgorithm.getGapForLocation(
832                 /* fractionToShade= */ 0f,
833                 /* onKeyguard= */ true,
834             )
835         assertThat(gap).isEqualTo(smallGap)
836     }
837 
838     @Test
839     fun getGapForLocation_goingToShade_interpolatesGap() {
840         val gap =
841             stackScrollAlgorithm.getGapForLocation(
842                 /* fractionToShade= */ 0.5f,
843                 /* onKeyguard= */ true,
844             )
845         assertThat(gap).isEqualTo(smallGap * 0.5f + bigGap * 0.5f)
846     }
847 
848     @Test
849     fun getGapForLocation_notOnLockscreen_returnsBigGap() {
850         val gap =
851             stackScrollAlgorithm.getGapForLocation(
852                 /* fractionToShade= */ 0f,
853                 /* onKeyguard= */ false,
854             )
855         assertThat(gap).isEqualTo(bigGap)
856     }
857 
858     @Test
859     fun updateViewWithShelf_viewAboveShelf_viewShown() {
860         val viewStart = 0f
861         val shelfStart = 1f
862 
863         val expandableView = mock<ExpandableView>()
864         whenever(expandableView.isExpandAnimationRunning).thenReturn(false)
865         whenever(expandableView.hasExpandingChild()).thenReturn(false)
866 
867         val expandableViewState = ExpandableViewState()
868         expandableViewState.yTranslation = viewStart
869 
870         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
871         assertThat(expandableViewState.hidden).isFalse()
872     }
873 
874     @Test
875     fun updateViewWithShelf_viewBelowShelf_viewHidden() {
876         val shelfStart = 0f
877         val viewStart = 1f
878 
879         val expandableView = mock<ExpandableView>()
880         whenever(expandableView.isExpandAnimationRunning).thenReturn(false)
881         whenever(expandableView.hasExpandingChild()).thenReturn(false)
882 
883         val expandableViewState = ExpandableViewState()
884         expandableViewState.yTranslation = viewStart
885 
886         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
887         assertThat(expandableViewState.hidden).isTrue()
888     }
889 
890     @Test
891     fun updateViewWithShelf_viewBelowShelfButIsExpanding_viewShown() {
892         val shelfStart = 0f
893         val viewStart = 1f
894 
895         val expandableView = mock<ExpandableView>()
896         whenever(expandableView.isExpandAnimationRunning).thenReturn(true)
897         whenever(expandableView.hasExpandingChild()).thenReturn(true)
898 
899         val expandableViewState = ExpandableViewState()
900         expandableViewState.yTranslation = viewStart
901 
902         stackScrollAlgorithm.updateViewWithShelf(expandableView, expandableViewState, shelfStart)
903         assertThat(expandableViewState.hidden).isFalse()
904     }
905 
906     @Test
907     fun maybeUpdateHeadsUpIsVisible_endVisible_true() {
908         val expandableViewState = ExpandableViewState()
909         expandableViewState.headsUpIsVisible = false
910 
911         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
912             expandableViewState,
913             /* isShadeExpanded= */ true,
914             /* mustStayOnScreen= */ true,
915             /* topVisible = */ true,
916             /* viewEnd= */ 0f,
917             /* hunMax = */ 10f,
918         )
919 
920         assertThat(expandableViewState.headsUpIsVisible).isTrue()
921     }
922 
923     @Test
924     fun maybeUpdateHeadsUpIsVisible_endHidden_false() {
925         val expandableViewState = ExpandableViewState()
926         expandableViewState.headsUpIsVisible = true
927 
928         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
929             expandableViewState,
930             /* isShadeExpanded= */ true,
931             /* mustStayOnScreen= */ true,
932             /* topVisible = */ true,
933             /* viewEnd= */ 10f,
934             /* hunMax = */ 0f,
935         )
936 
937         assertThat(expandableViewState.headsUpIsVisible).isFalse()
938     }
939 
940     @Test
941     fun maybeUpdateHeadsUpIsVisible_shadeClosed_noUpdate() {
942         val expandableViewState = ExpandableViewState()
943         expandableViewState.headsUpIsVisible = true
944 
945         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
946             expandableViewState,
947             /* isShadeExpanded= */ false,
948             /* mustStayOnScreen= */ true,
949             /* topVisible = */ true,
950             /* viewEnd= */ 10f,
951             /* hunMax = */ 1f,
952         )
953 
954         assertThat(expandableViewState.headsUpIsVisible).isTrue()
955     }
956 
957     @Test
958     fun maybeUpdateHeadsUpIsVisible_notHUN_noUpdate() {
959         val expandableViewState = ExpandableViewState()
960         expandableViewState.headsUpIsVisible = true
961 
962         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
963             expandableViewState,
964             /* isShadeExpanded= */ true,
965             /* mustStayOnScreen= */ false,
966             /* topVisible = */ true,
967             /* viewEnd= */ 10f,
968             /* hunMax = */ 1f,
969         )
970 
971         assertThat(expandableViewState.headsUpIsVisible).isTrue()
972     }
973 
974     @Test
975     fun maybeUpdateHeadsUpIsVisible_topHidden_noUpdate() {
976         val expandableViewState = ExpandableViewState()
977         expandableViewState.headsUpIsVisible = true
978 
979         stackScrollAlgorithm.maybeUpdateHeadsUpIsVisible(
980             expandableViewState,
981             /* isShadeExpanded= */ true,
982             /* mustStayOnScreen= */ true,
983             /* topVisible = */ false,
984             /* viewEnd= */ 10f,
985             /* hunMax = */ 1f,
986         )
987 
988         assertThat(expandableViewState.headsUpIsVisible).isTrue()
989     }
990 
991     @Test
992     fun clampHunToTop_viewYGreaterThanQqs_viewYUnchanged() {
993         val expandableViewState = ExpandableViewState()
994         expandableViewState.yTranslation = 50f
995 
996         stackScrollAlgorithm.clampHunToTop(
997             /* headsUpTop= */ 10f,
998             /* collapsedHeight= */ 1f,
999             expandableViewState,
1000         )
1001 
1002         // qqs (10 + 0) < viewY (50)
1003         assertThat(expandableViewState.yTranslation).isEqualTo(50f)
1004     }
1005 
1006     @Test
1007     fun clampHunToTop_viewYLessThanQqs_viewYChanged() {
1008         val expandableViewState = ExpandableViewState()
1009         expandableViewState.yTranslation = -10f
1010 
1011         stackScrollAlgorithm.clampHunToTop(
1012             /* headsUpTop= */ 10f,
1013             /* collapsedHeight= */ 1f,
1014             expandableViewState,
1015         )
1016 
1017         // qqs (10 + 0) > viewY (-10)
1018         assertThat(expandableViewState.yTranslation).isEqualTo(10f)
1019     }
1020 
1021     @Test
1022     fun clampHunToTop_viewYFarAboveVisibleStack_heightCollapsed() {
1023         val expandableViewState = ExpandableViewState()
1024         expandableViewState.height = 20
1025         expandableViewState.yTranslation = -100f
1026 
1027         stackScrollAlgorithm.clampHunToTop(
1028             /* headsUpTop= */ 10f,
1029             /* collapsedHeight= */ 10f,
1030             expandableViewState,
1031         )
1032 
1033         // newTranslation = max(10, -100) = 10
1034         // distToRealY = 10 - (-100f) = 110
1035         // height = max(20 - 110, 10f)
1036         assertThat(expandableViewState.height).isEqualTo(10)
1037     }
1038 
1039     @Test
1040     fun clampHunToTop_viewYNearVisibleStack_heightTallerThanCollapsed() {
1041         val expandableViewState = ExpandableViewState()
1042         expandableViewState.height = 20
1043         expandableViewState.yTranslation = 5f
1044 
1045         stackScrollAlgorithm.clampHunToTop(
1046             /* headsUpTop= */ 10f,
1047             /* collapsedHeight= */ 10f,
1048             expandableViewState,
1049         )
1050 
1051         // newTranslation = max(10, 5) = 10
1052         // distToRealY = 10 - 5 = 5
1053         // height = max(20 - 5, 10) = 15
1054         assertThat(expandableViewState.height).isEqualTo(15)
1055     }
1056 
1057     @Test
1058     fun computeCornerRoundnessForPinnedHun_stackBelowScreen_round() {
1059         val currentRoundness =
1060             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
1061                 /* hostViewHeight= */ 100f,
1062                 /* stackY= */ 110f,
1063                 /* viewMaxHeight= */ 20f,
1064                 /* originalCornerRadius = */ 0f,
1065             )
1066         assertThat(currentRoundness).isEqualTo(1f)
1067     }
1068 
1069     @Test
1070     fun computeCornerRoundnessForPinnedHun_stackAboveScreenBelowPinPoint_halfRound() {
1071         val currentRoundness =
1072             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
1073                 /* hostViewHeight= */ 100f,
1074                 /* stackY= */ 90f,
1075                 /* viewMaxHeight= */ 20f,
1076                 /* originalCornerRadius = */ 0f,
1077             )
1078         assertThat(currentRoundness).isEqualTo(0.5f)
1079     }
1080 
1081     @Test
1082     fun computeCornerRoundnessForPinnedHun_stackAbovePinPoint_notRound() {
1083         val currentRoundness =
1084             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
1085                 /* hostViewHeight= */ 100f,
1086                 /* stackY= */ 0f,
1087                 /* viewMaxHeight= */ 20f,
1088                 /* originalCornerRadius = */ 0f,
1089             )
1090         assertThat(currentRoundness).isZero()
1091     }
1092 
1093     @Test
1094     fun computeCornerRoundnessForPinnedHun_originallyRoundAndStackAbovePinPoint_round() {
1095         val currentRoundness =
1096             stackScrollAlgorithm.computeCornerRoundnessForPinnedHun(
1097                 /* hostViewHeight= */ 100f,
1098                 /* stackY= */ 0f,
1099                 /* viewMaxHeight= */ 20f,
1100                 /* originalCornerRadius = */ 1f,
1101             )
1102         assertThat(currentRoundness).isEqualTo(1f)
1103     }
1104 
1105     @Test
1106     @DisableSceneContainer
1107     fun shadeOpened_hunFullyOverlapsQqsPanel_hunShouldHaveFullShadow() {
1108         // Given: shade is opened, yTranslation of HUN is 0,
1109         // the height of HUN equals to the height of QQS Panel,
1110         // and HUN fully overlaps with QQS Panel
1111         ambientState.stackTranslation =
1112             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
1113         val childHunView =
1114             createHunViewMock(isShadeOpen = true, fullyVisible = false, headerVisibleAmount = 1f)
1115         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1116         algorithmState.visibleChildren.add(childHunView)
1117 
1118         // When: updateChildZValue() is called for the top HUN
1119         stackScrollAlgorithm.updateChildZValue(
1120             /* i= */ 0,
1121             /* childrenOnTop= */ 0.0f,
1122             /* algorithmState = */ algorithmState,
1123             /* ambientState= */ ambientState,
1124             /* isTopHun = */ true,
1125         )
1126 
1127         // Then: full shadow would be applied
1128         assertThat(childHunView.viewState.zTranslation)
1129             .isEqualTo(px(R.dimen.heads_up_pinned_elevation))
1130     }
1131 
1132     @Test
1133     @DisableSceneContainer
1134     fun shadeOpened_hunPartiallyOverlapsQQS_hunShouldHavePartialShadow() {
1135         // Given: shade is opened, yTranslation of HUN is greater than 0,
1136         // the height of HUN is equal to the height of QQS Panel,
1137         // and HUN partially overlaps with QQS Panel
1138         ambientState.stackTranslation =
1139             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
1140         val childHunView =
1141             createHunViewMock(isShadeOpen = true, fullyVisible = false, headerVisibleAmount = 1f)
1142         // Use half of the HUN's height as overlap
1143         childHunView.viewState.yTranslation = (childHunView.viewState.height + 1 shr 1).toFloat()
1144         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1145         algorithmState.visibleChildren.add(childHunView)
1146 
1147         // When: updateChildZValue() is called for the top HUN
1148         stackScrollAlgorithm.updateChildZValue(
1149             /* i= */ 0,
1150             /* childrenOnTop= */ 0.0f,
1151             /* algorithmState = */ algorithmState,
1152             /* ambientState= */ ambientState,
1153             /* isTopHun = */ true,
1154         )
1155 
1156         // Then: HUN should have shadow, but not as full size
1157         assertThat(childHunView.viewState.zTranslation).isGreaterThan(0.0f)
1158         assertThat(childHunView.viewState.zTranslation)
1159             .isLessThan(px(R.dimen.heads_up_pinned_elevation))
1160     }
1161 
1162     @Test
1163     @DisableSceneContainer
1164     fun shadeOpened_hunDoesNotOverlapQQS_hunShouldHaveNoShadow() {
1165         // Given: shade is opened, yTranslation of HUN is equal to QQS Panel's height,
1166         // the height of HUN is equal to the height of QQS Panel,
1167         // and HUN doesn't overlap with QQS Panel
1168         ambientState.stackTranslation =
1169             px(R.dimen.qqs_layout_margin_top) + px(R.dimen.qqs_layout_padding_bottom)
1170         // Mock the height of shade
1171         ambientState.setLayoutMinHeight(1000)
1172         val childHunView =
1173             createHunViewMock(isShadeOpen = true, fullyVisible = true, headerVisibleAmount = 1f)
1174         // HUN doesn't overlap with QQS Panel
1175         childHunView.viewState.yTranslation =
1176             ambientState.topPadding + ambientState.stackTranslation
1177         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1178         algorithmState.visibleChildren.add(childHunView)
1179 
1180         // When: updateChildZValue() is called for the top HUN
1181         stackScrollAlgorithm.updateChildZValue(
1182             /* i= */ 0,
1183             /* childrenOnTop= */ 0.0f,
1184             /* algorithmState = */ algorithmState,
1185             /* ambientState= */ ambientState,
1186             /* isTopHun = */ true,
1187         )
1188 
1189         // Then: HUN should not have shadow
1190         assertThat(childHunView.viewState.zTranslation).isZero()
1191     }
1192 
1193     @Test
1194     @DisableSceneContainer
1195     fun shadeClosed_hunShouldHaveFullShadow() {
1196         // Given: shade is closed, ambientState.stackTranslation == -ambientState.topPadding,
1197         // the height of HUN is equal to the height of QQS Panel,
1198         ambientState.stackTranslation = (-ambientState.topPadding).toFloat()
1199         // Mock the height of shade
1200         ambientState.setLayoutMinHeight(1000)
1201         val childHunView =
1202             createHunViewMock(isShadeOpen = false, fullyVisible = false, headerVisibleAmount = 0f)
1203         childHunView.viewState.yTranslation = 0f
1204         // Shade is closed, thus childHunView's headerVisibleAmount is 0
1205         childHunView.headerVisibleAmount = 0f
1206         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1207         algorithmState.visibleChildren.add(childHunView)
1208 
1209         // When: updateChildZValue() is called for the top HUN
1210         stackScrollAlgorithm.updateChildZValue(
1211             /* i= */ 0,
1212             /* childrenOnTop= */ 0.0f,
1213             /* algorithmState = */ algorithmState,
1214             /* ambientState= */ ambientState,
1215             /* isTopHun = */ true,
1216         )
1217 
1218         // Then: HUN should have full shadow
1219         assertThat(childHunView.viewState.zTranslation)
1220             .isEqualTo(px(R.dimen.heads_up_pinned_elevation))
1221     }
1222 
1223     @Test
1224     @DisableSceneContainer
1225     fun draggingHunToOpenShade_hunShouldHavePartialShadow() {
1226         // Given: shade is closed when HUN pops up,
1227         // now drags down the HUN to open shade
1228         ambientState.stackTranslation = (-ambientState.topPadding).toFloat()
1229         // Mock the height of shade
1230         ambientState.setLayoutMinHeight(1000)
1231         val childHunView =
1232             createHunViewMock(isShadeOpen = false, fullyVisible = false, headerVisibleAmount = 0.5f)
1233         childHunView.viewState.yTranslation = 0f
1234         // Shade is being opened, thus childHunView's headerVisibleAmount is between 0 and 1
1235         // use 0.5 as headerVisibleAmount here
1236         childHunView.headerVisibleAmount = 0.5f
1237         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1238         algorithmState.visibleChildren.add(childHunView)
1239 
1240         // When: updateChildZValue() is called for the top HUN
1241         stackScrollAlgorithm.updateChildZValue(
1242             /* i= */ 0,
1243             /* childrenOnTop= */ 0.0f,
1244             /* algorithmState = */ algorithmState,
1245             /* ambientState= */ ambientState,
1246             /* isTopHun = */ true,
1247         )
1248 
1249         // Then: HUN should have shadow, but not as full size
1250         assertThat(childHunView.viewState.zTranslation).isGreaterThan(0.0f)
1251         assertThat(childHunView.viewState.zTranslation)
1252             .isLessThan(px(R.dimen.heads_up_pinned_elevation))
1253     }
1254 
1255     @Test
1256     fun aodToLockScreen_hasPulsingNotification_pulsingNotificationRowDoesNotChange() {
1257         // Given: Before AOD to LockScreen, there was a pulsing notification
1258         val pulsingNotificationView = createPulsingViewMock()
1259         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1260         algorithmState.visibleChildren.add(pulsingNotificationView)
1261         ambientState.setPulsingRow(pulsingNotificationView)
1262 
1263         // When: during AOD to LockScreen, any dozeAmount between (0, 1.0) is equivalent as a middle
1264         // stage; here we use 0.5 for testing.
1265         // stackScrollAlgorithm.updatePulsingStates is called
1266         ambientState.dozeAmount = 0.5f
1267         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
1268 
1269         // Then: ambientState.pulsingRow should still be pulsingNotificationView
1270         assertThat(ambientState.isPulsingRow(pulsingNotificationView)).isTrue()
1271     }
1272 
1273     @Test
1274     fun deviceOnAod_hasPulsingNotification_recordPulsingNotificationRow() {
1275         // Given: Device is on AOD, there is a pulsing notification
1276         // ambientState.pulsingRow is null before stackScrollAlgorithm.updatePulsingStates
1277         ambientState.dozeAmount = 1.0f
1278         val pulsingNotificationView = createPulsingViewMock()
1279         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1280         algorithmState.visibleChildren.add(pulsingNotificationView)
1281         ambientState.setPulsingRow(null)
1282 
1283         // When: stackScrollAlgorithm.updatePulsingStates is called
1284         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
1285 
1286         // Then: ambientState.pulsingRow should record the pulsingNotificationView
1287         assertThat(ambientState.isPulsingRow(pulsingNotificationView)).isTrue()
1288     }
1289 
1290     @Test
1291     fun deviceOnLockScreen_hasPulsingNotificationBefore_clearPulsingNotificationRowRecord() {
1292         // Given: Device finished AOD to LockScreen, there was a pulsing notification, and
1293         // ambientState.pulsingRow was not null before AOD to LockScreen
1294         // pulsingNotificationView.showingPulsing() returns false since the device is on LockScreen
1295         ambientState.dozeAmount = 0.0f
1296         val pulsingNotificationView = createPulsingViewMock()
1297         whenever(pulsingNotificationView.showingPulsing()).thenReturn(false)
1298         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1299         algorithmState.visibleChildren.add(pulsingNotificationView)
1300         ambientState.setPulsingRow(pulsingNotificationView)
1301 
1302         // When: stackScrollAlgorithm.updatePulsingStates is called
1303         stackScrollAlgorithm.updatePulsingStates(algorithmState, ambientState)
1304 
1305         // Then: ambientState.pulsingRow should be null
1306         assertThat(ambientState.isPulsingRow(null)).isTrue()
1307     }
1308 
1309     @Test
1310     fun aodToLockScreen_hasPulsingNotification_pulsingNotificationRowShowAtFullHeight() {
1311         // Given: Before AOD to LockScreen, there was a pulsing notification
1312         val pulsingNotificationView = createPulsingViewMock()
1313         val algorithmState = StackScrollAlgorithm.StackScrollAlgorithmState()
1314         algorithmState.visibleChildren.add(pulsingNotificationView)
1315         ambientState.setPulsingRow(pulsingNotificationView)
1316 
1317         // When: during AOD to LockScreen, any dozeAmount between (0, 1.0) is equivalent as a middle
1318         // stage; here we use 0.5 for testing. The expansionFraction is also 0.5.
1319         // stackScrollAlgorithm.resetViewStates is called.
1320         ambientState.dozeAmount = 0.5f
1321         setExpansionFractionWithoutShelfDuringAodToLockScreen(
1322             ambientState,
1323             algorithmState,
1324             fraction = 0.5f,
1325         )
1326         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1327 
1328         // Then: pulsingNotificationView should show at full height
1329         assertThat(pulsingNotificationView.viewState.height)
1330             .isEqualTo(stackScrollAlgorithm.getMaxAllowedChildHeight(pulsingNotificationView))
1331 
1332         // After: reset dozeAmount and expansionFraction
1333         ambientState.dozeAmount = 0f
1334         setExpansionFractionWithoutShelfDuringAodToLockScreen(
1335             ambientState,
1336             algorithmState,
1337             fraction = 1f,
1338         )
1339     }
1340 
1341     // region shouldPinHunToBottomOfExpandedQs
1342     @Test
1343     fun shouldHunBeVisibleWhenScrolled_mustStayOnScreenFalse_false() {
1344         assertThat(
1345                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1346                     /* mustStayOnScreen= */ false,
1347                     /* headsUpIsVisible= */ false,
1348                     /* showingPulsing= */ false,
1349                     /* isOnKeyguard=*/ false,
1350                     /*headsUpOnKeyguard=*/ false,
1351                 )
1352             )
1353             .isFalse()
1354     }
1355 
1356     @Test
1357     fun shouldPinHunToBottomOfExpandedQs_headsUpIsVisible_false() {
1358         assertThat(
1359                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1360                     /* mustStayOnScreen= */ true,
1361                     /* headsUpIsVisible= */ true,
1362                     /* showingPulsing= */ false,
1363                     /* isOnKeyguard=*/ false,
1364                     /*headsUpOnKeyguard=*/ false,
1365                 )
1366             )
1367             .isFalse()
1368     }
1369 
1370     @Test
1371     fun shouldHunBeVisibleWhenScrolled_showingPulsing_false() {
1372         assertThat(
1373                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1374                     /* mustStayOnScreen= */ true,
1375                     /* headsUpIsVisible= */ false,
1376                     /* showingPulsing= */ true,
1377                     /* isOnKeyguard=*/ false,
1378                     /* headsUpOnKeyguard= */ false,
1379                 )
1380             )
1381             .isFalse()
1382     }
1383 
1384     @Test
1385     fun shouldHunBeVisibleWhenScrolled_isOnKeyguard_false() {
1386         assertThat(
1387                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1388                     /* mustStayOnScreen= */ true,
1389                     /* headsUpIsVisible= */ false,
1390                     /* showingPulsing= */ false,
1391                     /* isOnKeyguard=*/ true,
1392                     /* headsUpOnKeyguard= */ false,
1393                 )
1394             )
1395             .isFalse()
1396     }
1397 
1398     @Test
1399     fun shouldHunBeVisibleWhenScrolled_isNotOnKeyguard_true() {
1400         assertThat(
1401                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1402                     /* mustStayOnScreen= */ true,
1403                     /* headsUpIsVisible= */ false,
1404                     /* showingPulsing= */ false,
1405                     /* isOnKeyguard=*/ false,
1406                     /* headsUpOnKeyguard= */ false,
1407                 )
1408             )
1409             .isTrue()
1410     }
1411 
1412     @Test
1413     fun shouldHunBeVisibleWhenScrolled_headsUpOnKeyguard_true() {
1414         assertThat(
1415                 stackScrollAlgorithm.shouldHunBeVisibleWhenScrolled(
1416                     /* mustStayOnScreen= */ true,
1417                     /* headsUpIsVisible= */ false,
1418                     /* showingPulsing= */ false,
1419                     /* isOnKeyguard=*/ true,
1420                     /* headsUpOnKeyguard= */ true,
1421                 )
1422             )
1423             .isTrue()
1424     }
1425 
1426     @Test
1427     fun shouldHunAppearFromBottom_hunAtMaxHunTranslation() {
1428         ambientState.maxHeadsUpTranslation = 400f
1429         val viewState =
1430             ExpandableViewState().apply {
1431                 height = 100
1432                 yTranslation = ambientState.maxHeadsUpTranslation - height // move it to the max
1433             }
1434 
1435         assertThat(stackScrollAlgorithm.shouldHunAppearFromBottom(ambientState, viewState)).isTrue()
1436     }
1437 
1438     @Test
1439     fun shouldHunAppearFromBottom_hunBelowMaxHunTranslation() {
1440         ambientState.maxHeadsUpTranslation = 400f
1441         val viewState =
1442             ExpandableViewState().apply {
1443                 height = 100
1444                 yTranslation =
1445                     ambientState.maxHeadsUpTranslation - height - 1 // move it below the max
1446             }
1447 
1448         assertThat(stackScrollAlgorithm.shouldHunAppearFromBottom(ambientState, viewState))
1449             .isFalse()
1450     }
1451 
1452     // endregion
1453 
1454     private fun createHunViewMock(
1455         isShadeOpen: Boolean,
1456         fullyVisible: Boolean,
1457         headerVisibleAmount: Float,
1458     ) =
1459         mock<ExpandableNotificationRow>().apply {
1460             val childViewStateMock = createHunChildViewState(isShadeOpen, fullyVisible)
1461             whenever(this.viewState).thenReturn(childViewStateMock)
1462 
1463             whenever(this.mustStayOnScreen()).thenReturn(true)
1464             whenever(this.headerVisibleAmount).thenReturn(headerVisibleAmount)
1465         }
1466 
1467     private fun createHunChildViewState(isShadeOpen: Boolean, fullyVisible: Boolean) =
1468         ExpandableViewState().apply {
1469             // Mock the HUN's height with ambientState.topPadding +
1470             // ambientState.stackTranslation
1471             height = (ambientState.topPadding + ambientState.stackTranslation).toInt()
1472             if (isShadeOpen && fullyVisible) {
1473                 yTranslation = ambientState.topPadding + ambientState.stackTranslation
1474             } else {
1475                 yTranslation = 0f
1476             }
1477             headsUpIsVisible = fullyVisible
1478         }
1479 
1480     private fun createPulsingViewMock() =
1481         mock<ExpandableNotificationRow>().apply {
1482             whenever(this.viewState).thenReturn(ExpandableViewState())
1483             whenever(this.showingPulsing()).thenReturn(true)
1484         }
1485 
1486     private fun setExpansionFractionWithoutShelfDuringAodToLockScreen(
1487         ambientState: AmbientState,
1488         algorithmState: StackScrollAlgorithm.StackScrollAlgorithmState,
1489         fraction: Float,
1490     ) {
1491         // showingShelf: false
1492         algorithmState.firstViewInShelf = null
1493         // scrimPadding: 0, because device is on lock screen
1494         ambientState.setStatusBarState(StatusBarState.KEYGUARD)
1495         ambientState.dozeAmount = 0.0f
1496         // set stackEndHeight and stackHeight
1497         // ExpansionFractionWithoutShelf == stackHeight / stackEndHeight
1498         ambientState.stackEndHeight = 100f
1499         ambientState.interpolatedStackHeight = ambientState.stackEndHeight * fraction
1500     }
1501 
1502     private fun resetViewStates_hunYTranslationIs(expected: Float) {
1503         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1504 
1505         assertThat(notificationRow.viewState.yTranslation).isEqualTo(expected)
1506     }
1507 
1508     private fun resetViewStates_stackMargin_changesHunYTranslation() {
1509         val stackTopMargin = bigGap.toInt() // a gap smaller than the headsUpInset
1510         val headsUpTranslationY = stackScrollAlgorithm.mHeadsUpInset - stackTopMargin
1511 
1512         // we need the shelf to mock the real-life behaviour of StackScrollAlgorithm#updateChild
1513         ambientState.shelf = notificationShelf
1514 
1515         // split shade case with top margin introduced by shade's status bar
1516         ambientState.stackTopMargin = stackTopMargin
1517         stackScrollAlgorithm.resetViewStates(ambientState, 0)
1518 
1519         // heads up translation should be decreased by the top margin
1520         assertThat(notificationRow.viewState.yTranslation).isEqualTo(headsUpTranslationY)
1521     }
1522 
1523     private fun resetViewStates_hunsOverlapping_bottomHunClipped(
1524         topHun: ExpandableNotificationRow,
1525         bottomHun: ExpandableNotificationRow,
1526     ) {
1527         val topHunHeight =
1528             mContext.resources.getDimensionPixelSize(R.dimen.notification_content_min_height)
1529         val bottomHunHeight =
1530             mContext.resources.getDimensionPixelSize(R.dimen.notification_max_heads_up_height)
1531         whenever(topHun.intrinsicHeight).thenReturn(topHunHeight)
1532         whenever(bottomHun.intrinsicHeight).thenReturn(bottomHunHeight)
1533 
1534         // we need the shelf to mock the real-life behaviour of StackScrollAlgorithm#updateChild
1535         ambientState.shelf = notificationShelf
1536 
1537         // add two overlapping HUNs
1538         hostView.removeAllViews()
1539         hostView.addView(topHun)
1540         hostView.addView(bottomHun)
1541 
1542         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
1543 
1544         // the height shouldn't change
1545         assertThat(topHun.viewState.height).isEqualTo(topHunHeight)
1546         assertThat(bottomHun.viewState.height).isEqualTo(bottomHunHeight)
1547         // the HUN at the bottom should be clipped
1548         assertThat(topHun.viewState.clipBottomAmount).isEqualTo(0)
1549         assertThat(bottomHun.viewState.clipBottomAmount).isEqualTo(bottomHunHeight - topHunHeight)
1550     }
1551 
1552     private fun resetViewStates_expansionChanging_notificationAlphaUpdated(
1553         expansionFraction: Float,
1554         expectedAlpha: Float,
1555     ) {
1556         ambientState.isExpansionChanging = true
1557         ambientState.expansionFraction = expansionFraction
1558         stackScrollAlgorithm.initView(context)
1559 
1560         stackScrollAlgorithm.resetViewStates(ambientState, /* speedBumpIndex= */ 0)
1561 
1562         expect.that(notificationRow.viewState.alpha).isEqualTo(expectedAlpha)
1563     }
1564 
1565     /** fakes the notification row under test, to be a HUN in a fully opened shade */
1566     private fun fakeHunInShade(
1567         collapsedHeight: Int,
1568         intrinsicHeight: Int,
1569         headsUpTop: Float,
1570         headsUpBottom: Float = headsUpTop + intrinsicHeight, // assume all the space available
1571         stackTop: Float,
1572         stackCutoff: Float = 2000f,
1573         fullStackHeight: Float = 3000f,
1574     ) {
1575         ambientState.headsUpTop = headsUpTop
1576         if (NotificationsHunSharedAnimationValues.isEnabled) {
1577             headsUpAnimator.headsUpAppearHeightBottom = headsUpBottom.roundToInt()
1578         } else {
1579             ambientState.headsUpBottom = headsUpBottom
1580         }
1581         ambientState.stackTop = stackTop
1582         ambientState.stackCutoff = stackCutoff
1583 
1584         // shade is fully open
1585         ambientState.expansionFraction = 1.0f
1586         with(fullStackHeight) {
1587             ambientState.interpolatedStackHeight = this
1588             ambientState.stackEndHeight = this
1589         }
1590         stackScrollAlgorithm.setIsExpanded(true)
1591 
1592         whenever(notificationRow.headerVisibleAmount).thenReturn(1.0f)
1593         whenever(notificationRow.mustStayOnScreen()).thenReturn(true)
1594         whenever(notificationRow.isHeadsUp).thenReturn(true)
1595         whenever(notificationRow.collapsedHeight).thenReturn(collapsedHeight)
1596         whenever(notificationRow.intrinsicHeight).thenReturn(intrinsicHeight)
1597     }
1598 }
1599 
mockExpandableNotificationRownull1600 private fun mockExpandableNotificationRow(): ExpandableNotificationRow {
1601     return mock<ExpandableNotificationRow>().apply {
1602         whenever(viewState).thenReturn(ExpandableViewState())
1603     }
1604 }
1605 
mockFooterViewnull1606 private fun mockFooterView(height: Int): FooterView {
1607     return mock<FooterView>().apply {
1608         whenever(viewState).thenReturn(FooterViewState())
1609         whenever(intrinsicHeight).thenReturn(height)
1610     }
1611 }
1612 
AmbientStatenull1613 private fun AmbientState.fakeShowingStackOnLockscreen() {
1614     if (SceneContainerFlag.isEnabled) {
1615         isShowingStackOnLockscreen = true
1616         lockscreenStackFadeInProgress = 1f // stack is fully opaque
1617     } else {
1618         setStatusBarState(StatusBarState.KEYGUARD)
1619     }
1620 }
1621