1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.platform.systemui_tapl.ui
18 
19 import android.os.SystemClock
20 import android.platform.helpers.CommonUtils
21 import android.platform.systemui_tapl.utils.DeviceUtils.LONG_WAIT
22 import android.platform.systemui_tapl.utils.DeviceUtils.settingsResSelector
23 import android.platform.systemui_tapl.utils.DeviceUtils.sysuiResSelector
24 import android.platform.test.scenario.tapl_common.Gestures
25 import android.platform.test.scenario.tapl_common.TaplUiDevice
26 import android.platform.test.scenario.tapl_common.TaplUiObject
27 import android.platform.uiautomatorhelpers.DeviceHelpers.assertInvisible
28 import android.platform.uiautomatorhelpers.DeviceHelpers.assertVisible
29 import android.platform.uiautomatorhelpers.DeviceHelpers.betterSwipe
30 import android.platform.uiautomatorhelpers.DeviceHelpers.context
31 import android.platform.uiautomatorhelpers.DeviceHelpers.uiDevice
32 import android.platform.uiautomatorhelpers.FLING_GESTURE_INTERPOLATOR
33 import android.platform.uiautomatorhelpers.TracingUtils.trace
34 import android.view.WindowManager
35 import android.view.WindowMetrics
36 import androidx.test.uiautomator.By
37 import androidx.test.uiautomator.BySelector
38 import androidx.test.uiautomator.Direction
39 import androidx.test.uiautomator.UiObject2
40 import androidx.test.uiautomator.Until
41 import com.android.launcher3.tapl.LauncherInstrumentation
42 import com.android.systemui.Flags
43 import com.google.common.truth.StandardSubjectBuilder
44 import com.google.common.truth.Truth.assertThat
45 import com.google.common.truth.Truth.assertWithMessage
46 import kotlin.math.floor
47 
48 /** System UI test automation object representing the notification shade. */
49 class NotificationShade internal constructor() {
50     init {
51         if (CommonUtils.isSplitShade()) {
52             val qsBounds = quickSettingsContainer.visibleBounds
53             val notificationBounds = notificationShadeScrollContainer.visibleBounds
54             assertWithMessage(
55                     "Quick settings container is not positioned left to notification stack scroller"
56                 )
57                 .that(qsBounds.right <= notificationBounds.left)
58                 .isTrue()
59         }
60     }
61 
62     /* fromLockscreen= */
63     /** Returns the shade's notification stack. */
64     val notificationStack: NotificationStack
65         get() = NotificationStack(/* fromLockscreen= */ false)
66 
67     /**
68      * Returns a SystemUI test object representing the Quick Quick Settings element in the
69      * Notification Shade.
70      */
71     val quickQuickSettings: QuickQuickSettings
72         get() = QuickQuickSettings()
73 
74     /** Check whether QuickSettings are expanded in the NotificationShade. */
assertQuickSettingsExpandednull75     fun assertQuickSettingsExpanded() {
76         assertWithMessage("QuickQuickSettings is visible")
77             .waitUntilGone(QuickQuickSettings.UI_QUICK_QUICK_SETTINGS_CONTAINER_SELECTOR)
78     }
79 
80     /** Check whether QuickSettings are collapsed in the NotificationShade. */
assertQuickSettingsCollapsednull81     fun assertQuickSettingsCollapsed() {
82         assertWithMessage("QuickQuickSettings not visible, shade is not collapsed")
83             .waitUntilVisible(QuickQuickSettings.UI_QUICK_QUICK_SETTINGS_CONTAINER_SELECTOR)
84     }
85 
verifyIsEmptynull86     fun verifyIsEmpty() {
87         assertWithMessage("Notification shade is not empty")
88             .waitUntilVisible(sysuiResSelector(UI_EMPTY_SHADE_VIEW_ID))
89     }
90 
verifyIsNotEmptynull91     fun verifyIsNotEmpty() {
92         assertWithMessage("Notification shade is empty")
93             .waitUntilGone(sysuiResSelector(UI_EMPTY_SHADE_VIEW_ID))
94     }
95 
verifyIsShowingFooternull96     fun verifyIsShowingFooter() {
97         assertWithMessage("Notification footer is invisible")
98             .waitUntilVisible(sysuiResSelector(UI_SETTINGS_BUTTON_ID))
99     }
100 
verifyIsNotShowingFooternull101     fun verifyIsNotShowingFooter() {
102         assertWithMessage("Notification footer is visible")
103             .waitUntilGone(sysuiResSelector(UI_SETTINGS_BUTTON_ID))
104     }
105 
106     /** Click Manage button to open notification settings page. */
openNotificationSettingsFromButtonnull107     fun openNotificationSettingsFromButton() {
108         val manageBtn =
109             if (Flags.notificationsRedesignFooterView())
110                 scrollAndFindButton("Notification settings")
111             else scrollAndFindButton("Manage")
112         assertThat(manageBtn).isNotNull()
113         Gestures.click(manageBtn!!, "Settings button")
114 
115         settingsResSelector("app_bar").assertVisible()
116     }
117 
scrollAndFindButtonnull118     private fun scrollAndFindButton(desc: String): UiObject2? {
119         var btn: UiObject2? = null
120         for (i in 0 until SCROLL_TIMES) {
121             btn = uiDevice.wait(Until.findObject(By.desc(desc)), LONG_WAIT.toMillis())
122             if (btn != null) {
123                 break
124             }
125             flingDown()
126         }
127         return btn
128     }
129 
130     /** Presses Clear All button. */
clearAllNotificationsnull131     fun clearAllNotifications() {
132         scrollToBottom()
133         val device = uiDevice
134         sysuiResSelector(UI_EMPTY_SHADE_VIEW_ID).assertInvisible {
135             "Shade is empty; cannot clear all"
136         }
137         TaplUiDevice.waitForObject(
138                 sysuiResSelector(UI_CLEAR_ALL_BUTTON_ID),
139                 objectName = "Clear All button",
140             )
141             .click()
142         waitForShadeToClose()
143         Root.get().goHomeViaKeycode()
144     }
145 
146     /**
147      * Performs a fling gesture from the bottom towards the top of the shade, thereby scrolling it
148      * down (or closing it where appropriate).
149      */
flingUpnull150     fun flingUp() {
151         fling(Direction.UP)
152     }
153 
154     /**
155      * Performs a fling gesture from the top towards the bottom of the shade, thereby scrolling it
156      * up (or opening the quick settings where appropriate).
157      */
flingDownnull158     fun flingDown() {
159         fling(Direction.DOWN)
160     }
161 
flingnull162     private fun fling(direction: Direction) {
163         val notificationObject = notificationsStack ?: error("Notification stack is not visible")
164         val notificationList = TaplUiObject(notificationObject, "Notification stack")
165         val notificationListY: Int = notificationObject.visibleBounds.height()
166         notificationList.setGestureMargin(floor(notificationListY * 0.2).toInt())
167         notificationList.fling(direction, 1.0f)
168         uiDevice.waitForIdle()
169     }
170 
171     /** Scrolls the shade to the bottom. */
scrollToBottomnull172     fun scrollToBottom() {
173         for (retries in 0 until Notification.MAX_FIND_BOTTOM_ATTEMPTS) {
174             // Checks the notification list has scrolled to the bottom or not
175             if (isShowingBottomOfShade) {
176                 notificationStack.assertShelfVisibility(/* visible= */ false)
177                 return
178             }
179             NotificationStack.scrollNotificationListOnce(Direction.DOWN)
180         }
181         throw AssertionError("Failed to find the bottom of the notification shade")
182     }
183 
184     /** Closes the shade. */
closenull185     fun close() {
186         val device = uiDevice
187         // Swipe in first quarter to avoid desktop windowing app handle interactions.
188         val swipeXCoordinate = device.displayWidth / 4
189         device.betterSwipe(
190             startX = swipeXCoordinate,
191             startY = screenBottom,
192             endX = swipeXCoordinate,
193             endY = 0,
194             interpolator = FLING_GESTURE_INTERPOLATOR,
195         )
196         waitForShadeToClose()
197     }
198 
199     /** Closes the shade with the back button. */
closeWithBackButtonnull200     fun closeWithBackButton() {
201         LauncherInstrumentation().pressBack()
202         waitForShadeToClose()
203     }
204 
205     // UiDevice#getDisplayHeight() excludes insets.
206     private val screenBottom: Int
207         get() {
208             val mWindowMetrics: WindowMetrics =
209                 context
210                     .getSystemService<WindowManager>(WindowManager::class.java)!!
211                     .getMaximumWindowMetrics()
212 
213             // UiDevice#getDisplayHeight() excludes insets.
214             return mWindowMetrics.getBounds().height() - 1
215         }
216 
217     /** Scrolls the shade down. */
scrollDownnull218     fun scrollDown() {
219         NotificationStack.scrollNotificationListOnce(Direction.DOWN)
220     }
221 
222     /** Scrolls the shade up. */
scrollUpnull223     fun scrollUp() {
224         NotificationStack.scrollNotificationListOnce(Direction.UP)
225     }
226 
227     /**
228      * Returns the type of the shade.
229      *
230      * This depends by the device characteristics (e.g. currently large screens in landscape has a
231      * split shade, composed by two columns)
232      */
233     val type: NotificationShadeType
234         get() {
235             val stackBounds = notificationsStack!!.visibleBounds
236             val stackWidth = stackBounds.width()
237             val displayWidth = uiDevice.displayWidth
238             return if (stackWidth <= displayWidth / 2) NotificationShadeType.SPLIT
239             else NotificationShadeType.NORMAL
240         }
241 
242     /** Opens quick settings via swipe. */
openQuickSettingsnull243     fun openQuickSettings(): QuickSettings {
244         val device = uiDevice
245         // Swipe in first quarter to avoid desktop windowing app handle interactions.
246         val swipeXCoordinate = device.displayWidth / 4
247         device.betterSwipe(
248             startX = swipeXCoordinate,
249             startY = 0,
250             endX = swipeXCoordinate,
251             endY = device.displayHeight,
252         )
253         SystemClock.sleep(SHORT_TIMEOUT.toLong())
254         return QuickSettings()
255     }
256 
257     /** Returns Quick Settings (aka expanded Quick Settings) or fails if it's not visible. */
258     val quickSettings: QuickSettings
259         get() = QuickSettings()
260 
261     /**
262      * Returns the visible UMO, or fails if it's not visible.
263      *
264      * **See:** [HSV](https://hsv.googleplex.com/5715413598994432?node=44)
265      */
266     val universalMediaObject: UniversalMediaObject
267         get() = UniversalMediaObject()
268 
269     /**
270      * Returns the QS header. Experimental.
271      *
272      * It provided both from here and from [QuickSettings] because there are slightly different
273      * layouts when QS are expanded and collapsed.
274      */
275     val header: QSHeader
276         get() = QSHeader()
277 
278     companion object {
279         private val QS_HEADER_SELECTOR = sysuiResSelector("split_shade_status_bar")
280         private const val WAIT_TIME = 10_000L
281         private const val UI_EMPTY_SHADE_VIEW_ID = "no_notifications"
282         private val UI_SETTINGS_BUTTON_ID =
283             if (Flags.notificationsRedesignFooterView()) "settings_button" else "manage_text"
284         private const val UI_QS_CONTAINER_ID = "quick_settings_container"
285         private const val UI_RESPONSE_TIMEOUT_MSECS: Long = 3000
286         private const val UI_CLEAR_ALL_BUTTON_ID = "dismiss_text"
287         private const val SHORT_TRANSITION_WAIT: Long = 1500
288         private const val UI_NOTIFICATION_LIST_ID = "notification_stack_scroller"
289         private const val SCROLL_TIMES = 3
290         private const val SHORT_TIMEOUT = 500
291         const val NOTIFICATION_MAX_HIERARCHY_DEPTH = 4
292         const val EXPANDABLE_NOTIFICATION_ROW = "expandableNotificationRow"
293         const val SHELF_ID = "notificationShelf"
294         const val UI_SCROLLABLE_ELEMENT_ID = "notification_stack_scroller"
295         const val HEADER_EXPAND_BUTTON = "expand_button"
296         val notificationsStack: UiObject2?
297             get() =
298                 uiDevice.wait(
299                     Until.findObject(sysuiResSelector(UI_NOTIFICATION_LIST_ID)),
300                     SHORT_TRANSITION_WAIT,
301                 )
302 
303         val isShowingBottomOfShade: Boolean
304             get() = isShowingEmptyShade || isShowingFooter
305 
306         private val isShowingEmptyShade: Boolean
307             get() = uiDevice.hasObject(sysuiResSelector(UI_EMPTY_SHADE_VIEW_ID))
308 
309         private val isShowingFooter: Boolean
310             get() = uiDevice.hasObject(sysuiResSelector(UI_SETTINGS_BUTTON_ID))
311 
312         private val quickSettingsContainer: UiObject2
313             get() =
314                 uiDevice.wait(
315                     Until.findObject(sysuiResSelector(UI_QS_CONTAINER_ID)),
316                     UI_RESPONSE_TIMEOUT_MSECS,
317                 ) ?: error("Can't find qs container.")
318 
319         private val notificationShadeScrollContainer: UiObject2
320             get() =
321                 uiDevice.wait(
322                     Until.findObject(sysuiResSelector(UI_SCROLLABLE_ELEMENT_ID)),
323                     UI_RESPONSE_TIMEOUT_MSECS,
324                 ) ?: error("Can't find notification shade scroll container.")
325 
326         @JvmStatic
waitForShadeToClosenull327         fun waitForShadeToClose() {
328             trace("waitForShadeToClose") {
329                 // QS header view used in all configurations of Notification shade.
330                 QS_HEADER_SELECTOR.assertInvisible { "Notification shade didn't close" }
331                 // Asserts on new QS resId.
332                 sysuiResSelector("shade_header_root").assertInvisible {
333                     "Notification shade didn't close"
334                 }
335             }
336         }
337     }
338 }
339 
waitUntilGonenull340 private fun StandardSubjectBuilder.waitUntilGone(selector: BySelector) {
341     that(uiDevice.wait(Until.gone(selector), LONG_WAIT.toMillis())).isTrue()
342 }
343 
waitUntilVisiblenull344 private fun StandardSubjectBuilder.waitUntilVisible(selector: BySelector) {
345     that(uiDevice.wait(Until.hasObject(selector), LONG_WAIT.toMillis())).isTrue()
346 }
347