1 /*
<lambda>null2  * 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.graphics.PointF
20 import android.graphics.Rect
21 import android.platform.helpers.ui.UiAutomatorUtils.getUiDevice
22 import android.platform.systemui_tapl.controller.NotificationIdentity
23 import android.platform.systemui_tapl.ui.NotificationStack.Companion.NOTIFICATION_ROW_SELECTOR
24 import android.platform.systemui_tapl.ui.NotificationStack.Companion.getNotificationCountByIdentityText
25 import android.platform.systemui_tapl.ui.NotificationStack.Companion.notificationByTextSelector
26 import android.platform.systemui_tapl.utils.DeviceUtils.LONG_WAIT
27 import android.platform.systemui_tapl.utils.DeviceUtils.SHORT_WAIT
28 import android.platform.systemui_tapl.utils.DeviceUtils.androidResSelector
29 import android.platform.systemui_tapl.utils.DeviceUtils.sysuiResSelector
30 import android.platform.test.scenario.tapl_common.Gestures
31 import android.platform.test.scenario.tapl_common.TaplUiDevice
32 import android.platform.uiautomatorhelpers.BetterSwipe
33 import android.platform.uiautomatorhelpers.DeviceHelpers.assertInvisible
34 import android.platform.uiautomatorhelpers.DeviceHelpers.assertVisibility
35 import android.platform.uiautomatorhelpers.DeviceHelpers.betterSwipe
36 import android.platform.uiautomatorhelpers.DeviceHelpers.uiDevice
37 import android.platform.uiautomatorhelpers.DeviceHelpers.waitForNullableObj
38 import android.platform.uiautomatorhelpers.DeviceHelpers.waitForObj
39 import android.platform.uiautomatorhelpers.FLING_GESTURE_INTERPOLATOR
40 import android.platform.uiautomatorhelpers.WaitUtils.ensureThat
41 import android.platform.uiautomatorhelpers.WaitUtils.retryIfStale
42 import androidx.test.platform.app.InstrumentationRegistry
43 import androidx.test.uiautomator.By
44 import androidx.test.uiautomator.BySelector
45 import androidx.test.uiautomator.UiObject2
46 import androidx.test.uiautomator.Until
47 import com.google.common.truth.Truth.assertThat
48 import com.google.common.truth.Truth.assertWithMessage
49 import java.time.Duration
50 import org.junit.Assert.assertNull
51 
52 /** System UI test automation object representing a notification in the notification shade. */
53 class Notification
54 internal constructor(
55     private val notification: UiObject2,
56     private val fromLockscreen: Boolean,
57     private val isHeadsUpNotification: Boolean,
58     private val groupNotificationIdentity: NotificationIdentity? = null,
59     // Selector of a view visible only in the expanded state.
60     private val contentIsVisibleInCollapsedState: Boolean = false,
61     private val isBigText: Boolean? = null,
62     private val pkg: String? = null,
63     selectorWhenExpanded: BySelector? = null,
64 ) : Sized(notification.visibleBounds) {
65 
66     private val selectorWhenExpanded: BySelector = selectorWhenExpanded ?: COLLAPSE_SELECTOR
67 
68     /**
69      * Verifies that the notification is in collapsed or expanded state.
70      *
71      * @param expectedExpanded whether the expected state is "expanded".
72      */
73     fun verifyExpanded(expectedExpanded: Boolean) {
74         notification.assertVisibility(selector = selectorWhenExpanded, visible = expectedExpanded)
75     }
76 
77     /**
78      * Taps the chevron or swipes the specified notification to expand it from the collapsed state.
79      *
80      * @param dragging By swiping when `true`, by tapping the chevron otherwise.
81      */
82     fun expand(dragging: Boolean) {
83         if (groupNotificationIdentity != null) {
84             expandGroup(dragging)
85         } else {
86             verifyExpanded(false)
87             toggleNonGroup(dragging, wasExpanded = false)
88             verifyExpanded(true)
89         }
90     }
91 
92     /**
93      * Taps the chevron or swipes the specified notification to collapse it from the expanded state.
94      *
95      * @param dragging By swiping when `true`, by tapping the chevron otherwise.
96      */
97     fun collapse(dragging: Boolean) {
98         assertNull("Collapsing groups is not supported", groupNotificationIdentity)
99         verifyExpanded(true)
100         toggleNonGroup(dragging, wasExpanded = true)
101         verifyExpanded(false)
102     }
103 
104     /** Dismisses the notification via swipe. */
105     fun dismiss() {
106         val rowCountBeforeSwipe = expandableNotificationRows.size
107         swipeRightOnNotification()
108 
109         // Since one group notification was swiped away, the new size shall be smaller
110         ensureThat("Number of notifications decreases after swipe") {
111             expandableNotificationRows.size < rowCountBeforeSwipe
112         }
113         // group notification shall not been found again
114         groupNotificationIdentity?.let {
115             notificationByTextSelector(it.summary!!).assertInvisible()
116         }
117     }
118 
119     fun waitUntilGone() {
120         notification.assertVisibility(TITLE_SELECTOR, false)
121     }
122 
123     /**
124      * Verifies that the notification is in HUN state. HUN State: A notification that has the expand
125      * button (chevron) at the “expand” status, and has at least an action that is currently
126      * showing. We only allow assertion of HUN state for notifications that have action buttons.
127      * Fails if the notification is not at the HUN state defined above.
128      */
129     fun verifyIsHunState() {
130         notification.assertVisibility(
131             selector = androidResSelector(EXPAND_BUTTON_ID).desc("Expand"),
132             visible = true,
133             errorProvider = {
134                 "HUN state assertion error: The notification is found, but not " +
135                     "in the HUN status, because didn't find the expand_button at the Expand status."
136             },
137         )
138         notification.assertVisibility(
139             selector = ACTION_BUTTON_SELECTOR,
140             visible = true,
141             errorProvider = {
142                 "HUN state assertion error: The notification is found, but not " +
143                     "in the HUN status, because didn't find an action button."
144             },
145         )
146     }
147 
148     /** Swipes on the notification but not able to dismiss the notification. */
149     fun swipeButNotDismiss() {
150         val rowCountBeforeSwipe = expandableNotificationRows.size
151         swipeRightOnNotification()
152 
153         // Since one group notification was swiped away, the new size shall be smaller
154         ensureThat("Number of notifications keeping the same after swipe") {
155             expandableNotificationRows.size == rowCountBeforeSwipe
156         }
157     }
158 
159     /**
160      * Swipes vertically on the specified notification. When the notification is a HUN (heads up
161      * notification), this expands the shade.
162      */
163     fun expandShadeFromHun(): NotificationShade {
164         assertWithMessage("Not a heads-up notification").that(isHeadsUpNotification).isTrue()
165         // drag straight downward by 1/4 of the screen size
166         val center = notification.visibleCenter
167         uiDevice.betterSwipe(
168             startX = center.x,
169             startY = center.y,
170             endX = center.x,
171             endY = center.y + uiDevice.displayHeight / 2,
172             interpolator = FLING_GESTURE_INTERPOLATOR,
173         )
174 
175         val shade = NotificationShade()
176         // swipe to show full list. Throws if we aren't in the shade
177         shade.scrollToBottom()
178         shade.verifyIsShowingFooter()
179         return shade
180     }
181 
182     /** Returns this notification object's visible bounds. */
183     fun getBounds(): Rect {
184         return notification.visibleBounds
185     }
186 
187     private fun toggleNonGroup(dragging: Boolean, wasExpanded: Boolean) {
188         check(isBigText != null) { "It is needed to know isBigText to use toggle notification" }
189         expandNotification(dragging)
190 
191         InstrumentationRegistry.getInstrumentation().uiAutomation.clearCache()
192 
193         // Expansion indicator be visible on the expanded state, and hidden on the collapsed one.
194         if (wasExpanded) {
195             assertThat(notification.wait(Until.gone(selectorWhenExpanded), TIMEOUT_MS)).isTrue()
196 
197             notification.assertVisibility(By.text(APP_NAME), false)
198             notification.assertVisibility(
199                 By.text(NOTIFICATION_CONTENT_TEXT),
200                 visible = contentIsVisibleInCollapsedState,
201             )
202             notification.assertVisibility(By.text(NOTIFICATION_BIG_TEXT), false)
203         } else {
204             assertThat(notification.wait(Until.hasObject(selectorWhenExpanded), TIMEOUT_MS))
205                 .isTrue()
206 
207             // Expanded state must contain app name.
208             notification.assertVisibility(By.text(APP_NAME), true)
209             if (isBigText) {
210                 notification.assertVisibility(By.text(NOTIFICATION_BIG_TEXT), true)
211             } else {
212                 notification.assertVisibility(By.text(NOTIFICATION_CONTENT_TEXT), true)
213             }
214         }
215         notification.assertVisibility(TITLE_SELECTOR, true)
216         notification.assertVisibility(androidResSelector(APP_ICON_ID), true)
217         notification.assertVisibility(androidResSelector(EXPAND_BUTTON_ID), true)
218     }
219 
220     private fun expandNotification(dragging: Boolean) {
221         val height: Int = notification.visibleBounds.height()
222         if (dragging) {
223             val center = notification.visibleCenter
224             uiDevice.betterSwipe(
225                 startX = center.x,
226                 startY = center.y,
227                 endX = center.x,
228                 endY = center.y + 300,
229                 interpolator = FLING_GESTURE_INTERPOLATOR,
230             )
231         } else {
232             tapExpandButton()
233         }
234 
235         // There isn't an explicit contract for notification expansion, so let's assert
236         // that the content height changed, which is likely.
237         ensureThat("Notification height changed") { notification.visibleBounds.height() != height }
238     }
239 
240     fun tapExpandButton() {
241         val chevron = notification.waitForObj(androidResSelector(EXPAND_BUTTON_ID))
242         Gestures.click(chevron, "Chevron")
243     }
244 
245     private fun expandGroup(dragging: Boolean) {
246         check(dragging) { "Only expanding by dragging is supported for group notifications" }
247         val collapsedNotificationsCount =
248             getNotificationCountByIdentityText(groupNotificationIdentity!!)
249 
250         // drag group notification to bottom to expand group
251         val center = notification.visibleCenter
252         uiDevice.betterSwipe(
253             startX = center.x,
254             startY = center.y,
255             endX = uiDevice.displayWidth / 2,
256             endY = uiDevice.displayHeight,
257             interpolator = FLING_GESTURE_INTERPOLATOR,
258         )
259 
260         // swipe to show full list
261         NotificationShade().scrollToBottom()
262 
263         // make sure the group notification expanded
264         ensureThat("Notification count increases") {
265             val expandNotificationsCount =
266                 getNotificationCountByIdentityText(groupNotificationIdentity)
267             collapsedNotificationsCount < expandNotificationsCount
268         }
269     }
270 
271     /** Returns number of messages in the notification. */
272     val messageCount: Int
273         get() = notification.waitForObj(MESSAGE_SELECTOR).children.size
274 
275     /** Long press on notification to show its hidden menu (a.k.a. guts) */
276     fun showGuts(): NotificationGuts {
277         val longClick = Gestures.longClickDown(notification, "Notification")
278         val guts = notification.waitForObj(GUTS_SELECTOR, UI_RESPONSE_TIMEOUT)
279         guts.assertVisibility(By.text(APP_NAME), true)
280         guts.assertVisibility(By.text(NOTIFICATION_CHANNEL_NAME), true)
281 
282         // Confirmation/Settings buttons
283         guts.assertVisibility(GUTS_SETTINGS_SELECTOR, true)
284         guts.assertVisibility(GUTS_CLOSE_SELECTOR, true)
285         longClick.up()
286         return NotificationGuts(notification)
287     }
288 
289     /** Clicks the notification and verifies that the expected app opens. */
290     fun clickToApp() {
291         Gestures.click(notification, "Notification")
292         verifyStartedApp()
293     }
294 
295     /** Clicks the notification to open the bouncer. */
296     fun clickToBouncer(): Bouncer {
297         assertWithMessage("The notification should be a lockscreen one")
298             .that(fromLockscreen)
299             .isTrue()
300         Gestures.click(notification, "Notification")
301         return Bouncer(/* notification= */ this)
302     }
303 
304     fun verifyStartedApp() {
305         check(
306             uiDevice.wait(Until.hasObject(By.pkg(pkg!!).depth(0)), LAUNCH_APP_TIMEOUT.toMillis())
307         ) {
308             "Did not find application, ${pkg}, in foreground"
309         }
310     }
311 
312     /** Clicks "show bubble" button to show a bubble. */
313     fun showBubble() {
314         // Create bubble from the notification
315         TaplUiDevice.waitForObject(BUBBLE_BUTTON_SELECTOR, "Show bubble button").click()
316 
317         // Verify that a bubble is visible
318         Root.get().bubble
319     }
320 
321     /** Taps the snooze button on the notification */
322     fun snooze(): Notification = also {
323         ensureThat { notification.isLongClickable }
324 
325         val snoozeButton = notification.waitForObj(SNOOZE_BUTTON_SELECTOR)
326 
327         Gestures.click(snoozeButton, "Snooze button")
328 
329         notification.assertVisibility(UNDO_BUTTON_SELECTOR, true)
330 
331         ensureThat { !notification.isLongClickable }
332     }
333 
334     /** Taps undo button on the snoozed notification */
335     fun unsnooze(): Notification = also {
336         ensureThat { !notification.isLongClickable }
337 
338         val undoButton = notification.waitForObj(UNDO_BUTTON_SELECTOR)
339 
340         Gestures.click(undoButton, "Undo Snooze button")
341 
342         notification.assertVisibility(SNOOZE_BUTTON_SELECTOR, true)
343 
344         ensureThat { notification.isLongClickable }
345     }
346 
347     /** Verifies that the given notification action is enabled/disabled */
348     fun verifyActionIsEnabled(actionSelectorText: String, expectedEnabledState: Boolean) {
349         val actionButton =
350             notification.wait(
351                 Until.findObject(By.text(actionSelectorText)),
352                 UI_RESPONSE_TIMEOUT.toMillis(),
353             )
354 
355         assertThat(actionButton).isNotNull()
356 
357         ensureThat { actionButton.isEnabled == expectedEnabledState }
358     }
359 
360     fun verifyTitleEquals(expected: String) {
361         waitForObj(
362             By.copy(TITLE_SELECTOR).text(expected),
363             errorProvider = { "Couldn't find title with text \"$expected\"" },
364         )
365     }
366 
367     fun verifyBigTextEquals(expected: String) {
368         waitForObj(
369             By.copy(BIG_TEXT_SELECTOR).text(expected),
370             errorProvider = { "Couldn't find big text with text \"$expected\"" },
371         )
372     }
373 
374     fun clickButton(label: String) {
375         notification.waitForObj(By.text(label)).click()
376     }
377 
378     /**
379      * Press the reply button, enter [text] to reply with and send.
380      *
381      * NOTE: Prefer using shorter strings here, as longer ones tend to have a significant effect on
382      * performance on slower test devices.
383      */
384     fun replyWithText(text: String) {
385         // This sometimes has issues where it can't find the reply button due to a
386         // StaleObjectException, although the button is there. So we attempt each interaciton
387         // three times, separately.
388         retryIfStale(description = "find reply button", times = 3) {
389             notification.waitForObj(REPLY_BUTTON_SELECTOR, SHORT_WAIT).click()
390         }
391 
392         var remoteInputSelector: UiObject2? = null
393         retryIfStale(description = "add reply text \"$text\"", times = 3) {
394             remoteInputSelector = notification.waitForObj(REMOTE_INPUT_TEXT_SELECTOR, LONG_WAIT)
395         }
396         if (remoteInputSelector == null) {
397             // If the screen is too small, it might be hidden by IME.
398             // Dismiss the IME and try again.
399             getUiDevice().pressBack()
400             retryIfStale(description = "add reply text \"$text\"", times = 3) {
401                 remoteInputSelector = notification.waitForObj(REMOTE_INPUT_TEXT_SELECTOR, LONG_WAIT)
402             }
403         }
404         remoteInputSelector?.text = text
405 
406         var sendSelector: UiObject2? = null
407         retryIfStale(description = "find send selector input", times = 3) {
408             sendSelector = notification.waitForObj(REMOTE_INPUT_SEND_SELECTOR, SHORT_WAIT)
409         }
410         if (sendSelector == null) {
411             // If the screen is too small, it might be hidden by IME.
412             // Dismiss the IME and try again.
413             getUiDevice().pressBack()
414             retryIfStale(description = "find send selector input", times = 3) {
415                 sendSelector = notification.waitForObj(REMOTE_INPUT_SEND_SELECTOR, SHORT_WAIT)
416             }
417         }
418         sendSelector?.click()
419     }
420 
421     fun assertReplyHistoryContains(reply: String) {
422         ensureThat("Reply history should contain \"$reply\"") { replyHistoryContains(reply) }
423     }
424 
425     fun getUiObject(): UiObject2 = notification
426 
427     private fun replyHistoryContains(reply: String): Boolean {
428         // Fail if we cannot find the container
429         val container = notification.waitForObj(REPLY_HISTORY_CONTAINER, LONG_WAIT)
430         return (1..3).any { i ->
431             val replyObject =
432                 container.waitForNullableObj(getReplyHistorySelector(i), SHORT_WAIT)
433                     ?: return false // We don't expect more replies
434             replyObject.text == reply
435         }
436     }
437 
438     private fun swipeRightOnNotification() {
439         val bounds = notification.visibleBounds
440         val centerY = (bounds.top + bounds.bottom) / 2f
441         BetterSwipe.from(PointF(bounds.left.toFloat(), centerY))
442             .to(PointF(bounds.right.toFloat(), centerY), interpolator = FLING_GESTURE_INTERPOLATOR)
443             .release()
444     }
445 
446     companion object {
447         private const val APP_NAME = "Scenario"
448         private val UI_RESPONSE_TIMEOUT = Duration.ofSeconds(3)
449         private val LAUNCH_APP_TIMEOUT = Duration.ofSeconds(10)
450         private val SHORT_TRANSITION_WAIT = Duration.ofMillis(1500)
451         private val TIMEOUT_MS = LONG_WAIT.toMillis()
452 
453         private val TITLE_SELECTOR = androidResSelector("title")
454         private val MESSAGE_SELECTOR = androidResSelector("group_message_container")
455         private val COLLAPSE_SELECTOR = By.descContains("Collapse")
456         private val GUTS_SELECTOR = sysuiResSelector("notification_guts").maxDepth(1)
457         private const val NOTIFICATION_CHANNEL_NAME = "Test Channel DEFAULT_IMPORTANCE"
458         private val GUTS_SETTINGS_SELECTOR = sysuiResSelector("info")
459         private val GUTS_CLOSE_SELECTOR = sysuiResSelector("done")
460         private val BUBBLE_BUTTON_SELECTOR = By.res("android:id/bubble_button")
461         private val SNOOZE_BUTTON_SELECTOR = androidResSelector("snooze_button")
462         private val UNDO_BUTTON_SELECTOR = By.text("Undo")
463         private val ACTION_BUTTON_SELECTOR = androidResSelector("action0")
464         private val BIG_TEXT_SELECTOR = androidResSelector("big_text")
465 
466         // RemoteInput selectors
467         private val REPLY_BUTTON_SELECTOR = androidResSelector("action0").descContains("Reply")
468         private val REMOTE_INPUT_TEXT_SELECTOR = sysuiResSelector("remote_input_text")
469         private val REMOTE_INPUT_SEND_SELECTOR = sysuiResSelector("remote_input_send")
470         private val REPLY_HISTORY_CONTAINER =
471             androidResSelector("notification_material_reply_container")
472 
473         private fun getReplyHistorySelector(index: Int) =
474             androidResSelector("notification_material_reply_text_$index")
475 
476         const val MAX_FIND_BOTTOM_ATTEMPTS = 15
477 
478         const val NOTIFICATION_TITLE_TEXT = "TEST NOTIFICATION"
479 
480         @JvmField
481         val NOTIFICATION_BIG_TEXT =
482             """
483             lorem ipsum dolor sit amet
484             lorem ipsum dolor sit amet
485             lorem ipsum dolor sit amet
486             lorem ipsum dolor sit amet
487             """
488                 .trimIndent()
489         private const val EXPAND_BUTTON_ID = "expand_button"
490         private const val APP_ICON_ID = "icon"
491         private const val NOTIFICATION_CONTENT_TEXT = "Test notification content"
492 
493         private val expandableNotificationRows: List<UiObject2>
494             get() {
495                 return uiDevice.wait(
496                     Until.findObjects(NOTIFICATION_ROW_SELECTOR),
497                     SHORT_TRANSITION_WAIT.toMillis(),
498                 ) ?: emptyList()
499             }
500     }
501 }
502