/* * Copyright (C) 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.systemui.shade import android.testing.AndroidTestingRunner import android.testing.TestableLooper import android.testing.TestableResources import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManagerPolicyConstants import androidx.annotation.IdRes import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.test.filters.SmallTest import com.android.systemui.R import com.android.systemui.SysuiTestCase import com.android.systemui.flags.FakeFeatureFlags import com.android.systemui.flags.Flags import com.android.systemui.fragments.FragmentHostManager import com.android.systemui.fragments.FragmentService import com.android.systemui.navigationbar.NavigationModeController import com.android.systemui.navigationbar.NavigationModeController.ModeChangedListener import com.android.systemui.plugins.qs.QS import com.android.systemui.recents.OverviewProxyService import com.android.systemui.recents.OverviewProxyService.OverviewProxyListener import com.android.systemui.statusbar.notification.stack.NotificationStackScrollLayoutController import com.android.systemui.util.concurrency.FakeExecutor import com.android.systemui.util.mockito.capture import com.android.systemui.util.mockito.whenever import com.android.systemui.util.time.FakeSystemClock import com.google.common.truth.Truth.assertThat import java.util.function.Consumer import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentCaptor import org.mockito.Captor import org.mockito.Mock import org.mockito.Mockito import org.mockito.Mockito.RETURNS_DEEP_STUBS import org.mockito.Mockito.any import org.mockito.Mockito.anyInt import org.mockito.Mockito.doNothing import org.mockito.Mockito.eq import org.mockito.Mockito.mock import org.mockito.Mockito.never import org.mockito.Mockito.reset import org.mockito.Mockito.verify import org.mockito.MockitoAnnotations /** Uses Flags.MIGRATE_NSSL set to false. If all goes well, this set of tests will be deleted. */ @RunWith(AndroidTestingRunner::class) @TestableLooper.RunWithLooper @SmallTest class NotificationsQSContainerControllerLegacyTest : SysuiTestCase() { @Mock lateinit var view: NotificationsQuickSettingsContainer @Mock lateinit var navigationModeController: NavigationModeController @Mock lateinit var overviewProxyService: OverviewProxyService @Mock lateinit var shadeHeaderController: ShadeHeaderController @Mock lateinit var shadeExpansionStateManager: ShadeExpansionStateManager @Mock lateinit var fragmentService: FragmentService @Mock lateinit var fragmentHostManager: FragmentHostManager @Mock lateinit var notificationStackScrollLayoutController: NotificationStackScrollLayoutController @Captor lateinit var navigationModeCaptor: ArgumentCaptor @Captor lateinit var taskbarVisibilityCaptor: ArgumentCaptor @Captor lateinit var windowInsetsCallbackCaptor: ArgumentCaptor> @Captor lateinit var constraintSetCaptor: ArgumentCaptor @Captor lateinit var attachStateListenerCaptor: ArgumentCaptor lateinit var underTest: NotificationsQSContainerController private lateinit var fakeResources: TestableResources private lateinit var featureFlags: FakeFeatureFlags private lateinit var navigationModeCallback: ModeChangedListener private lateinit var taskbarVisibilityCallback: OverviewProxyListener private lateinit var windowInsetsCallback: Consumer private lateinit var fakeSystemClock: FakeSystemClock private lateinit var delayableExecutor: FakeExecutor @Before fun setup() { MockitoAnnotations.initMocks(this) fakeSystemClock = FakeSystemClock() delayableExecutor = FakeExecutor(fakeSystemClock) featureFlags = FakeFeatureFlags().apply { set(Flags.MIGRATE_NSSL, false) } mContext.ensureTestableResources() whenever(view.context).thenReturn(mContext) whenever(view.resources).thenReturn(mContext.resources) whenever(fragmentService.getFragmentHostManager(any())).thenReturn(fragmentHostManager) underTest = NotificationsQSContainerController( view, navigationModeController, overviewProxyService, shadeHeaderController, shadeExpansionStateManager, fragmentService, delayableExecutor, featureFlags, notificationStackScrollLayoutController, ) overrideResource(R.dimen.split_shade_notifications_scrim_margin_bottom, SCRIM_MARGIN) overrideResource(R.dimen.notification_panel_margin_bottom, NOTIFICATIONS_MARGIN) overrideResource(R.bool.config_use_split_notification_shade, false) overrideResource(R.dimen.qs_footer_actions_bottom_padding, FOOTER_ACTIONS_PADDING) overrideResource(R.dimen.qs_footer_action_inset, FOOTER_ACTIONS_INSET) whenever(navigationModeController.addListener(navigationModeCaptor.capture())) .thenReturn(GESTURES_NAVIGATION) doNothing().`when`(overviewProxyService).addCallback(taskbarVisibilityCaptor.capture()) doNothing().`when`(view).setInsetsChangedListener(windowInsetsCallbackCaptor.capture()) doNothing().`when`(view).applyConstraints(constraintSetCaptor.capture()) doNothing().`when`(view).addOnAttachStateChangeListener(attachStateListenerCaptor.capture()) underTest.init() attachStateListenerCaptor.value.onViewAttachedToWindow(view) navigationModeCallback = navigationModeCaptor.value taskbarVisibilityCallback = taskbarVisibilityCaptor.value windowInsetsCallback = windowInsetsCallbackCaptor.value Mockito.clearInvocations(view) } @Test fun testSmallScreen_updateResources_splitShadeHeightIsSet() { overrideResource(R.bool.config_use_large_screen_shade_header, false) overrideResource(R.dimen.qs_header_height, 10) overrideResource(R.dimen.large_screen_shade_header_height, 20) // ensure the estimated height (would be 3 here) wouldn't impact this test case overrideResource(R.dimen.large_screen_shade_header_min_height, 1) overrideResource(R.dimen.new_qs_header_non_clickable_element_height, 1) underTest.updateResources() val captor = ArgumentCaptor.forClass(ConstraintSet::class.java) verify(view).applyConstraints(capture(captor)) assertThat(captor.value.getHeight(R.id.split_shade_status_bar)).isEqualTo(10) } @Test fun testLargeScreen_updateResources_splitShadeHeightIsSet() { overrideResource(R.bool.config_use_large_screen_shade_header, true) overrideResource(R.dimen.qs_header_height, 10) overrideResource(R.dimen.large_screen_shade_header_height, 20) // ensure the estimated height (would be 3 here) wouldn't impact this test case overrideResource(R.dimen.large_screen_shade_header_min_height, 1) overrideResource(R.dimen.new_qs_header_non_clickable_element_height, 1) underTest.updateResources() val captor = ArgumentCaptor.forClass(ConstraintSet::class.java) verify(view).applyConstraints(capture(captor)) assertThat(captor.value.getHeight(R.id.split_shade_status_bar)).isEqualTo(20) } @Test fun testSmallScreen_estimatedHeightIsLargerThanDimenValue_shadeHeightIsSetToEstimatedHeight() { overrideResource(R.bool.config_use_large_screen_shade_header, false) overrideResource(R.dimen.qs_header_height, 10) overrideResource(R.dimen.large_screen_shade_header_height, 20) // make the estimated height (would be 15 here) larger than qs_header_height overrideResource(R.dimen.large_screen_shade_header_min_height, 5) overrideResource(R.dimen.new_qs_header_non_clickable_element_height, 5) underTest.updateResources() val captor = ArgumentCaptor.forClass(ConstraintSet::class.java) verify(view).applyConstraints(capture(captor)) assertThat(captor.value.getHeight(R.id.split_shade_status_bar)).isEqualTo(15) } @Test fun testTaskbarVisibleInSplitShade() { enableSplitShade() given( taskbarVisible = true, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = 0, // taskbar should disappear when shade is expanded expectedNotificationsMargin = NOTIFICATIONS_MARGIN, expectedQsPadding = NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) given( taskbarVisible = true, navigationMode = BUTTONS_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = STABLE_INSET_BOTTOM, expectedNotificationsMargin = NOTIFICATIONS_MARGIN, expectedQsPadding = NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) } @Test fun testTaskbarNotVisibleInSplitShade() { // when taskbar is not visible, it means we're on the home screen enableSplitShade() given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = 0, expectedQsPadding = NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) given( taskbarVisible = false, navigationMode = BUTTONS_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = 0, // qs goes full height as it's not obscuring nav buttons expectedNotificationsMargin = STABLE_INSET_BOTTOM + NOTIFICATIONS_MARGIN, expectedQsPadding = STABLE_INSET_BOTTOM + NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) } @Test fun testTaskbarNotVisibleInSplitShadeWithCutout() { enableSplitShade() given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withCutout() ) then( expectedContainerPadding = CUTOUT_HEIGHT, expectedQsPadding = NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) given( taskbarVisible = false, navigationMode = BUTTONS_NAVIGATION, insets = windowInsets().withCutout().withStableBottom() ) then( expectedContainerPadding = 0, expectedNotificationsMargin = STABLE_INSET_BOTTOM + NOTIFICATIONS_MARGIN, expectedQsPadding = STABLE_INSET_BOTTOM + NOTIFICATIONS_MARGIN - QS_PADDING_OFFSET ) } @Test fun testTaskbarVisibleInSinglePaneShade() { disableSplitShade() given( taskbarVisible = true, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withStableBottom() ) then(expectedContainerPadding = 0, expectedQsPadding = STABLE_INSET_BOTTOM) given( taskbarVisible = true, navigationMode = BUTTONS_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = STABLE_INSET_BOTTOM, expectedQsPadding = STABLE_INSET_BOTTOM ) } @Test fun testTaskbarNotVisibleInSinglePaneShade() { disableSplitShade() given(taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = emptyInsets()) then(expectedContainerPadding = 0) given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withCutout().withStableBottom() ) then(expectedContainerPadding = CUTOUT_HEIGHT, expectedQsPadding = STABLE_INSET_BOTTOM) given( taskbarVisible = false, navigationMode = BUTTONS_NAVIGATION, insets = windowInsets().withStableBottom() ) then( expectedContainerPadding = 0, expectedNotificationsMargin = STABLE_INSET_BOTTOM + NOTIFICATIONS_MARGIN, expectedQsPadding = STABLE_INSET_BOTTOM ) } @Test fun testDetailShowingInSinglePaneShade() { disableSplitShade() underTest.setDetailShowing(true) // always sets spacings to 0 given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withStableBottom() ) then(expectedContainerPadding = 0, expectedNotificationsMargin = 0) given(taskbarVisible = false, navigationMode = BUTTONS_NAVIGATION, insets = emptyInsets()) then(expectedContainerPadding = 0, expectedNotificationsMargin = 0) } @Test fun testDetailShowingInSplitShade() { enableSplitShade() underTest.setDetailShowing(true) given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = windowInsets().withStableBottom() ) then(expectedContainerPadding = 0) // should not influence spacing given(taskbarVisible = false, navigationMode = BUTTONS_NAVIGATION, insets = emptyInsets()) then(expectedContainerPadding = 0) } @Test fun testNotificationsMarginBottomIsUpdated() { Mockito.clearInvocations(view) enableSplitShade() verify(view).setNotificationsMarginBottom(NOTIFICATIONS_MARGIN) overrideResource(R.dimen.notification_panel_margin_bottom, 100) disableSplitShade() verify(view).setNotificationsMarginBottom(100) } @Test fun testSplitShadeLayout_isAlignedToGuideline() { enableSplitShade() underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).endToEnd).isEqualTo(R.id.qs_edge_guideline) assertThat(getConstraintSetLayout(R.id.notification_stack_scroller).startToStart) .isEqualTo(R.id.qs_edge_guideline) } @Test fun testSinglePaneLayout_childrenHaveEqualMargins() { disableSplitShade() underTest.updateResources() val qsStartMargin = getConstraintSetLayout(R.id.qs_frame).startMargin val qsEndMargin = getConstraintSetLayout(R.id.qs_frame).endMargin val notifStartMargin = getConstraintSetLayout(R.id.notification_stack_scroller).startMargin val notifEndMargin = getConstraintSetLayout(R.id.notification_stack_scroller).endMargin assertThat( qsStartMargin == qsEndMargin && notifStartMargin == notifEndMargin && qsStartMargin == notifStartMargin ) .isTrue() } @Test fun testSplitShadeLayout_childrenHaveInsideMarginsOfZero() { enableSplitShade() underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).endMargin).isEqualTo(0) assertThat(getConstraintSetLayout(R.id.notification_stack_scroller).startMargin) .isEqualTo(0) } @Test fun testSplitShadeLayout_qsFrameHasHorizontalMarginsOfZero() { enableSplitShade() underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).endMargin).isEqualTo(0) assertThat(getConstraintSetLayout(R.id.qs_frame).startMargin).isEqualTo(0) } @Test fun testLargeScreenLayout_qsAndNotifsTopMarginIsOfHeaderHeight() { setLargeScreen() val largeScreenHeaderHeight = 100 overrideResource(R.dimen.large_screen_shade_header_height, largeScreenHeaderHeight) // ensure the estimated height (would be 30 here) wouldn't impact this test case overrideResource(R.dimen.large_screen_shade_header_min_height, 10) overrideResource(R.dimen.new_qs_header_non_clickable_element_height, 10) underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).topMargin) .isEqualTo(largeScreenHeaderHeight) assertThat(getConstraintSetLayout(R.id.notification_stack_scroller).topMargin) .isEqualTo(largeScreenHeaderHeight) } @Test fun testSmallScreenLayout_qsAndNotifsTopMarginIsZero() { setSmallScreen() underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).topMargin).isEqualTo(0) assertThat(getConstraintSetLayout(R.id.notification_stack_scroller).topMargin).isEqualTo(0) } @Test fun testSinglePaneShadeLayout_qsFrameHasHorizontalMarginsSetToCorrectValue() { disableSplitShade() underTest.updateResources() val notificationPanelMarginHorizontal = mContext.resources.getDimensionPixelSize(R.dimen.notification_panel_margin_horizontal) assertThat(getConstraintSetLayout(R.id.qs_frame).endMargin) .isEqualTo(notificationPanelMarginHorizontal) assertThat(getConstraintSetLayout(R.id.qs_frame).startMargin) .isEqualTo(notificationPanelMarginHorizontal) } @Test fun testSinglePaneShadeLayout_isAlignedToParent() { disableSplitShade() underTest.updateResources() assertThat(getConstraintSetLayout(R.id.qs_frame).endToEnd) .isEqualTo(ConstraintSet.PARENT_ID) assertThat(getConstraintSetLayout(R.id.notification_stack_scroller).startToStart) .isEqualTo(ConstraintSet.PARENT_ID) } @Test fun testAllChildrenOfNotificationContainer_haveIds() { // set dimen to 0 to avoid triggering updating bottom spacing overrideResource(R.dimen.split_shade_notifications_scrim_margin_bottom, 0) val container = NotificationsQuickSettingsContainer(mContext, null) container.removeAllViews() container.addView(newViewWithId(1)) container.addView(newViewWithId(View.NO_ID)) val controller = NotificationsQSContainerController( container, navigationModeController, overviewProxyService, shadeHeaderController, shadeExpansionStateManager, fragmentService, delayableExecutor, featureFlags, notificationStackScrollLayoutController, ) controller.updateConstraints() assertThat(container.getChildAt(0).id).isEqualTo(1) assertThat(container.getChildAt(1).id).isNotEqualTo(View.NO_ID) } @Test fun testWindowInsetDebounce() { disableSplitShade() given( taskbarVisible = false, navigationMode = GESTURES_NAVIGATION, insets = emptyInsets(), applyImmediately = false ) fakeSystemClock.advanceTime(INSET_DEBOUNCE_MILLIS / 2) windowInsetsCallback.accept(windowInsets().withStableBottom()) delayableExecutor.advanceClockToLast() delayableExecutor.runAllReady() verify(view, never()).setQSContainerPaddingBottom(0) verify(view).setQSContainerPaddingBottom(STABLE_INSET_BOTTOM) } @Test fun testStartCustomizingWithDuration() { underTest.setCustomizerShowing(true, 100L) verify(shadeHeaderController).startCustomizingAnimation(true, 100L) } @Test fun testEndCustomizingWithDuration() { underTest.setCustomizerShowing(true, 0L) // Only tracks changes reset(shadeHeaderController) underTest.setCustomizerShowing(false, 100L) verify(shadeHeaderController).startCustomizingAnimation(false, 100L) } @Test fun testTagListenerAdded() { verify(fragmentHostManager).addTagListener(eq(QS.TAG), eq(view)) } @Test fun testTagListenerRemoved() { attachStateListenerCaptor.value.onViewDetachedFromWindow(view) verify(fragmentHostManager).removeTagListener(eq(QS.TAG), eq(view)) } private fun disableSplitShade() { setSplitShadeEnabled(false) } private fun enableSplitShade() { setSplitShadeEnabled(true) } private fun setSplitShadeEnabled(enabled: Boolean) { overrideResource(R.bool.config_use_split_notification_shade, enabled) underTest.updateResources() } private fun setSmallScreen() { setLargeScreenEnabled(false) } private fun setLargeScreen() { setLargeScreenEnabled(true) } private fun setLargeScreenEnabled(enabled: Boolean) { overrideResource(R.bool.config_use_large_screen_shade_header, enabled) } private fun given( taskbarVisible: Boolean, navigationMode: Int, insets: WindowInsets, applyImmediately: Boolean = true ) { Mockito.clearInvocations(view) taskbarVisibilityCallback.onTaskbarStatusUpdated(taskbarVisible, false) navigationModeCallback.onNavigationModeChanged(navigationMode) windowInsetsCallback.accept(insets) if (applyImmediately) { delayableExecutor.advanceClockToLast() delayableExecutor.runAllReady() } } fun then( expectedContainerPadding: Int, expectedNotificationsMargin: Int = NOTIFICATIONS_MARGIN, expectedQsPadding: Int = 0 ) { verify(view).setPadding(anyInt(), anyInt(), anyInt(), eq(expectedContainerPadding)) verify(view).setNotificationsMarginBottom(expectedNotificationsMargin) verify(view).setQSContainerPaddingBottom(expectedQsPadding) Mockito.clearInvocations(view) } private fun windowInsets() = mock(WindowInsets::class.java, RETURNS_DEEP_STUBS) private fun emptyInsets() = mock(WindowInsets::class.java) private fun WindowInsets.withCutout(): WindowInsets { whenever(checkNotNull(displayCutout).safeInsetBottom).thenReturn(CUTOUT_HEIGHT) return this } private fun WindowInsets.withStableBottom(): WindowInsets { whenever(stableInsetBottom).thenReturn(STABLE_INSET_BOTTOM) return this } private fun getConstraintSetLayout(@IdRes id: Int): ConstraintSet.Layout { return constraintSetCaptor.value.getConstraint(id).layout } private fun newViewWithId(id: Int): View { val view = View(mContext) view.id = id val layoutParams = ConstraintLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) // required as cloning ConstraintSet fails if view doesn't have layout params view.layoutParams = layoutParams return view } companion object { const val STABLE_INSET_BOTTOM = 100 const val CUTOUT_HEIGHT = 50 const val GESTURES_NAVIGATION = WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL const val BUTTONS_NAVIGATION = WindowManagerPolicyConstants.NAV_BAR_MODE_3BUTTON const val NOTIFICATIONS_MARGIN = 50 const val SCRIM_MARGIN = 10 const val FOOTER_ACTIONS_INSET = 2 const val FOOTER_ACTIONS_PADDING = 2 const val FOOTER_ACTIONS_OFFSET = FOOTER_ACTIONS_INSET + FOOTER_ACTIONS_PADDING const val QS_PADDING_OFFSET = SCRIM_MARGIN + FOOTER_ACTIONS_OFFSET } }