1 /*
<lambda>null2  * Copyright (C) 2022 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.graphics.Rect
20 import android.platform.systemui_tapl.controller.NotificationIdentity
21 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.BIG_PICTURE
22 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.BIG_TEXT
23 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.BY_TEXT
24 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.BY_TITLE
25 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.CALL
26 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.CONVERSATION
27 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.CUSTOM
28 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.GROUP
29 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.GROUP_AUTO_GENERATED
30 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.GROUP_MINIMIZED
31 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.INBOX
32 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.MEDIA
33 import android.platform.systemui_tapl.controller.NotificationIdentity.Type.MESSAGING_STYLE
34 import android.platform.systemui_tapl.ui.NotificationShade.Companion.SHELF_ID
35 import android.platform.systemui_tapl.utils.DeviceUtils.LONG_WAIT
36 import android.platform.systemui_tapl.utils.DeviceUtils.androidResSelector
37 import android.platform.systemui_tapl.utils.DeviceUtils.sysuiResSelector
38 import android.platform.test.scenario.tapl_common.TaplUiObject
39 import android.platform.test.util.HealthTestingUtils.waitForValueToSettle
40 import android.platform.uiautomatorhelpers.DeviceHelpers.assertVisibility
41 import android.platform.uiautomatorhelpers.DeviceHelpers.assertVisible
42 import android.platform.uiautomatorhelpers.DeviceHelpers.uiDevice
43 import android.platform.uiautomatorhelpers.DeviceHelpers.waitForNullableObj
44 import android.platform.uiautomatorhelpers.DeviceHelpers.waitForNullableObjects
45 import android.platform.uiautomatorhelpers.DeviceHelpers.waitForObj
46 import android.platform.uiautomatorhelpers.WaitUtils.ensureThat
47 import android.platform.uiautomatorhelpers.WaitUtils.retryIfStale
48 import androidx.test.uiautomator.By
49 import androidx.test.uiautomator.BySelector
50 import androidx.test.uiautomator.Direction
51 import androidx.test.uiautomator.UiObject2
52 import androidx.test.uiautomator.Until
53 import java.time.Duration
54 import java.util.regex.Pattern
55 import kotlin.math.floor
56 import org.junit.Assert.assertTrue
57 
58 /**
59  * Represents the stack of notifications, terminating with the optional [NotificationShelf].
60  *
61  * This might be shown on several places:
62  * - Lockscreen ([LockscreenNotificationShade])
63  * - Notification shade ([NotificationShade]), pulled-down from the top of the screen.
64  */
65 open class NotificationStack internal constructor(val fromLockscreen: Boolean) {
66 
67     init {
68         NOTIFICATION_STACK_SCROLLER.assertVisible { "Notification stack scroller didn't appear" }
69     }
70 
71     /** Fails when shelf visibility doesn't match [visible] within a timeout. */
72     fun assertShelfVisibility(visible: Boolean) {
73         uiDevice.assertVisibility(NOTIFICATION_SHELF_SELECTOR, visible) {
74             "Notification shelf is ${if (visible) { "invisible" } else {"visible"}}"
75         }
76     }
77 
78     /** Fails when [notifications] size doesn't become [expected] within a timeout. */
79     fun assertVisibleNotificationCount(expected: Int) {
80         val errorMessage = {
81             "Notification count didn't match expectations. " +
82                 "Count=${notifications.size}, expected=$expected"
83         }
84         ensureThat("Visible notifications count match", errorProvider = errorMessage) {
85             notifications.size == expected
86         }
87     }
88 
89     /** Fails when [notifications] count doesn't become at least [expected] within a timeout. */
90     fun assertVisibleNotificationCountAtLeast(expected: Int) {
91         val errorMessage = {
92             "Notification count didn't match expectations. " +
93                 "Count=${notifications.size}, expected>=$expected"
94         }
95         ensureThat("Visible notifications count at least $expected", errorProvider = errorMessage) {
96             notifications.size >= expected
97         }
98     }
99 
100     /** Returns visible notifications. */
101     val notifications: List<Notification>
102         get() =
103             waitForValueToSettle(/* errorMessage= */ { "visibleNotifications didn't settle." }) {
104                 visibleNotifications.map { uiObject ->
105                     Notification(uiObject, fromLockscreen, isHeadsUpNotification = false)
106                 }
107             }
108 
109     /** Returns visible notifications if able, otherwise [null]. */
110     fun tryGetNotifications(): List<Notification>? {
111         return retryIfStale(description = "tryGetNotifications", times = 3) {
112             return@retryIfStale notifications
113         }
114     }
115 
116     /**
117      * Waits for visible notifications to settle (the same number of notifications for several
118      * seconds)
119      */
120     fun waitForNotificationsToSettle() {
121         // Accessing visible notifications waits for them to settle
122         notifications
123     }
124 
125     /** @return the bounds of the notification shade. */
126     fun getShadeBounds(): Rect {
127         val stack = waitForObj(NOTIFICATION_STACK_SCROLLER)
128         return Rect().apply { stack.children.forEach { child -> union(child.visibleBounds) } }
129     }
130 
131     fun getShelfBounds(): Rect {
132         return waitForObj(NOTIFICATION_SHELF_SELECTOR).visibleBounds
133     }
134 
135     /** Returns the [NotificationShelf] if visible, otherwise [null]. */
136     fun tryGetNotificationShelf(): NotificationShelf? {
137         return retryIfStale(description = "tryGetNotificationShelf", times = 3) {
138             return@retryIfStale waitForValueToSettle(
139                 /* errorMessage= */ { "Notification shelf didn't settle." }) {
140                 notificationShelfObject?.let { NotificationShelf(rect = it.visibleBounds) }
141             }
142         }
143     }
144 
145     /**
146      * Finds a notification by its identity. Fails if the notification can't be found.
147      *
148      * @param identity description of the notification tyoe and properties
149      * @param waitTimeout duration to wait for notification to appear.
150      * @return Notification (throws assertion if not found)
151      */
152     @JvmOverloads
153     fun findNotification(
154         identity: NotificationIdentity,
155         waitTimeout: Duration = LONG_WAIT,
156     ): Notification =
157         findNotificationInternal(
158             identity,
159             fromLockscreen,
160             isHeadsUpNotification = false,
161             scroll = false,
162             waitTimeout = waitTimeout,
163         )
164 
165     /**
166      * Scrolls to a notification defined by its identity. Fails if the notification can't be found
167      * in the shade.
168      *
169      * @param identity description of the notification tyoe and properties
170      * @param waitTimeout duration to wait for notification to appear.
171      * @return Notification (throws assertion if not found)
172      */
173     fun scrollToNotification(
174         identity: NotificationIdentity,
175         waitTimeout: Duration = LONG_WAIT,
176     ): Notification =
177         findNotificationInternal(
178             identity,
179             fromLockscreen,
180             isHeadsUpNotification = false,
181             scroll = true,
182             waitTimeout = waitTimeout,
183         )
184 
185     companion object {
186 
187         /**
188          * Finds a HUN by its identity. Fails if the notification can't be found.
189          *
190          * @param identity The NotificationIdentity used to find the HUN
191          * @param assertIsHunState When it's true, findHeadsUpNotification would fail if the
192          *   notification is not at the HUN state (eg. showing in the Shade), or its HUN state
193          *   cannot be verified. An action button is necessary for the verification. Consider
194          *   posting the HUN with NotificationController#postBigTextHeadsUpNotification if you need
195          *   to assert the HUN state. Expanded HUN state cannot be asserted.
196          * @param waitTimeout duration to wait for the notification to appear.
197          * @return Notification (throws assertion if not found)
198          */
199         @JvmOverloads
200         @JvmStatic
201         internal fun findHeadsUpNotification(
202             identity: NotificationIdentity,
203             assertIsHunState: Boolean = true,
204             waitTimeout: Duration = LONG_WAIT,
205         ): Notification {
206             if (!assertIsHunState) {
207                 return findNotificationInternal(
208                     identity = identity,
209                     fromLockscreen = false,
210                     isHeadsUpNotification = true,
211                     scroll = false,
212                     waitTimeout = waitTimeout,
213                 )
214             }
215 
216             assertTrue(
217                 "HUN state Assertion usage error: Notification: ${identity.title} " +
218                     "| You can only assert the HUN State of a notification that has an action " +
219                     "button. Add an action button to the notification or set assertHeadsUpState " +
220                     "to false.",
221                 identity.hasAction,
222             )
223 
224             val notification =
225                 findNotificationInternal(
226                     identity,
227                     fromLockscreen = false,
228                     isHeadsUpNotification = true,
229                     scroll = false,
230                     waitTimeout = waitTimeout,
231                 )
232             notification.verifyIsHunState()
233             return notification
234         }
235 
236         /**
237          * Finds a notification by its identity. Fails is the notification can't be found.
238          *
239          * @param identity description of the notification tyoe and properties.
240          * @param fromLockscreen flag set in the returned Notification object.
241          * @param isHeadsUpNotification flag set in the returned Notification object.
242          * @param scroll allow scrolling to find the notification in the notification stak.
243          * @param waitTimeout duration to wait for notification to appear.
244          * @return Notification (throws assertion if not found)
245          */
246         @JvmStatic
247         private fun findNotificationInternal(
248             identity: NotificationIdentity,
249             fromLockscreen: Boolean,
250             isHeadsUpNotification: Boolean,
251             scroll: Boolean,
252             waitTimeout: Duration,
253         ): Notification {
254 
255             // Generate the selector for the expanded notification.
256             val selectorWhenExpanded: BySelector? =
257                 when (identity.type) {
258                     GROUP,
259                     GROUP_AUTO_GENERATED,
260                     GROUP_MINIMIZED -> null
261                     BIG_TEXT -> By.text(identity.textWhenExpanded!!)
262                     BIG_PICTURE -> BIG_PICTURE_SELECTOR
263                     CUSTOM -> CUSTOM_NOTIFICATION_SELECTOR
264                     CALL,
265                     MEDIA,
266                     INBOX,
267                     BY_TEXT -> By.text(identity.text!!)
268                     MESSAGING_STYLE,
269                     CONVERSATION -> MESSAGE_ICON_CONTAINER_SELECTOR
270                     BY_TITLE -> notificationByTitleSelector(identity.title!!)
271                 }
272 
273             // Generate the selector for the notification
274             val selector =
275                 when (identity.type) {
276                     GROUP -> groupBySummarySelector(identity.summary!!)
277                     GROUP_MINIMIZED -> minimizedGroupBySummarySelector(identity.title!!)
278                     GROUP_AUTO_GENERATED -> autoGeneratedGroupByAppNameSelector(identity.summary!!)
279                     CALL,
280                     MEDIA,
281                     INBOX,
282                     MESSAGING_STYLE,
283                     CONVERSATION,
284                     BY_TEXT -> notificationByTextSelector(identity.text!!)
285                     BIG_TEXT -> notificationByTitleSelector(identity.title!!)
286                     CUSTOM -> CUSTOM_NOTIFICATION_SELECTOR
287                     BY_TITLE -> notificationByTitleSelector(identity.title!!)
288                     else -> notificationByTitleSelector(identity.text!!)
289                 }
290 
291             // If scrolling is enabled, scroll to the notification using the selector,
292             // otherwise, just wait for it.
293             val notification =
294                 if (scroll) {
295                     scrollToNotificationBySelector(selector)
296                 } else {
297                     waitForObj(selector, waitTimeout)
298                 }
299 
300             // Notification groups should have at least 2 children
301             if (identity.type == GROUP || identity.type == GROUP_AUTO_GENERATED) {
302                 val childCount = notification.findObjects(NOTIFICATION_ROW_SELECTOR).size
303                 assertTrue(
304                     "Wanted at least 2 children, but found only $childCount.",
305                     childCount >= 2,
306                 )
307             }
308             return if (identity.type == GROUP) {
309                 Notification(
310                     notification = notification,
311                     groupNotificationIdentity = identity,
312                     selectorWhenExpanded = selectorWhenExpanded,
313                     contentIsVisibleInCollapsedState = false,
314                     isBigText = true,
315                     pkg = identity.pkg,
316                     fromLockscreen = fromLockscreen,
317                     isHeadsUpNotification = isHeadsUpNotification,
318                 )
319             } else {
320                 Notification(
321                     notification = notification,
322                     selectorWhenExpanded = selectorWhenExpanded,
323                     contentIsVisibleInCollapsedState = identity.contentIsVisibleInCollapsedState,
324                     isBigText = identity.type == BIG_TEXT,
325                     pkg = identity.pkg,
326                     fromLockscreen = fromLockscreen,
327                     isHeadsUpNotification = isHeadsUpNotification,
328                 )
329             }
330         }
331 
332         private fun groupBySummarySelector(summary: String): BySelector {
333             return By.copy(NOTIFICATION_ROW_SELECTOR)
334                 .hasDescendant(androidResSelector("header_text").text(summary))
335                 .hasDescendant(NOTIFICATION_ROW_SELECTOR)
336         }
337 
338         private fun minimizedGroupBySummarySelector(summary: String): BySelector {
339             return By.copy(NOTIFICATION_ROW_SELECTOR)
340                 .hasDescendant(androidResSelector("header_text").text(summary))
341                 .hasDescendant(NOTIFICATION_HEADER_EXPAND_BUTTON_SELECTOR)
342         }
343 
344         private fun autoGeneratedGroupByAppNameSelector(appName: String): BySelector {
345             return By.copy(NOTIFICATION_ROW_SELECTOR)
346                 .hasDescendant(androidResSelector("app_name_text").text(appName))
347                 .hasDescendant(NOTIFICATION_ROW_SELECTOR)
348         }
349 
350         private fun notificationByTitleSelector(title: String) =
351             By.copy(NOTIFICATION_ROW_SELECTOR)
352                 .hasDescendant(androidResSelector("title").text(title))
353 
354         internal fun notificationByTextSelector(text: String) =
355             By.copy(NOTIFICATION_ROW_SELECTOR).hasDescendant(By.text(text))
356 
357         internal fun getNotificationCountByIdentityText(identity: NotificationIdentity): Int {
358             val notifications: Collection<UiObject2> =
359                 uiDevice.wait(
360                     Until.findObjects(notificationByTextSelector(identity.text!!)),
361                     LONG_WAIT.toMillis(),
362                 ) ?: throw AssertionError("Cannot find notifications with text '${identity.text}'")
363             return notifications.size
364         }
365 
366         private fun scrollToNotificationBySelector(selector: BySelector): UiObject2 {
367             // fail if the device doesn't become idle
368             uiDevice.waitForIdle(LONG_WAIT.toMillis())
369             // wait for the first element longer, maybe our notifications are not posted yet
370             var found = waitForNullableObj(selector, LONG_WAIT)
371             var scrolledToBottom = NotificationShade.isShowingBottomOfShade
372             var retries = 0
373             while (
374                 retries++ < MAX_FIND_NOTIFICATION_ATTEMPTS && !scrolledToBottom && found == null
375             ) {
376                 scrollNotificationListOnce(Direction.DOWN)
377                 scrolledToBottom = NotificationShade.isShowingBottomOfShade
378 
379                 found =
380                     waitForNullableObj(selector)?.takeIf {
381                         // only take this object, if it is entirely scrolled above the shelf
382                         scrolledToBottom || isAboveShelf(it)
383                     }
384             }
385 
386             return checkNotNull(found) { "Did not find notification matching $selector" }
387         }
388 
389         private fun isAboveShelf(notification: UiObject2): Boolean {
390             val stack: UiObject2 =
391                 NotificationShade.notificationsStack ?: error("Notification stack is not visible")
392             val shelf = stack.findObject(NOTIFICATION_SHELF_SELECTOR) ?: return true
393             return notification.visibleBounds.bottom < shelf.visibleBounds.top
394         }
395 
396         /** Performs one swipe to scroll notification list. */
397         internal fun scrollNotificationListOnce(direction: Direction) {
398             val notificationListObject2: UiObject2 =
399                 NotificationShade.notificationsStack ?: error("Notification stack is not visible")
400             val notificationList = TaplUiObject(notificationListObject2, "Notification stack")
401             val notificationListY: Int = notificationListObject2.visibleBounds.height()
402             notificationList.setGestureMargin(floor(notificationListY * 0.2).toInt())
403             notificationList.scroll(direction, 1.0f)
404         }
405 
406         private val notificationShelfObject: UiObject2?
407             get() = uiDevice.waitForNullableObj(NOTIFICATION_SHELF_SELECTOR)
408 
409         private val visibleNotifications: List<UiObject2>
410             get() = uiDevice.waitForNullableObjects(NOTIFICATION_ROW_SELECTOR) ?: emptyList()
411 
412         private const val MAX_FIND_NOTIFICATION_ATTEMPTS = 15
413         private val NOTIFICATION_SHELF_SELECTOR =
414             sysuiResSelector(SHELF_ID).maxDepth(NotificationShade.NOTIFICATION_MAX_HIERARCHY_DEPTH)
415         private val NOTIFICATION_STACK_SCROLLER = sysuiResSelector("notification_stack_scroller")
416         private val BIG_PICTURE_SELECTOR = androidResSelector("big_picture")
417         private val MESSAGE_ICON_CONTAINER_SELECTOR = androidResSelector("message_icon_container")
418         private val CUSTOM_NOTIFICATION_SELECTOR =
419             By.text(Pattern.compile("Example text|Example Text"))
420 
421         internal val NOTIFICATION_ROW_SELECTOR =
422             sysuiResSelector(NotificationShade.EXPANDABLE_NOTIFICATION_ROW)
423 
424         internal val NOTIFICATION_HEADER_EXPAND_BUTTON_SELECTOR =
425             androidResSelector(NotificationShade.HEADER_EXPAND_BUTTON)
426     }
427 }
428