<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