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