• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 + 280,
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         Gestures.longClickDownUp(
278             notification,
279             "Notification",
280             whileHoldingFn = {
281                 val guts = notification.waitForObj(GUTS_SELECTOR)
282                 guts.assertVisibility(By.text(APP_NAME), true)
283                 guts.assertVisibility(By.text(NOTIFICATION_CHANNEL_NAME), true)
284 
285                 // Confirmation/Settings buttons
286                 guts.assertVisibility(GUTS_SETTINGS_SELECTOR, true)
287                 guts.assertVisibility(GUTS_CLOSE_SELECTOR, true)
288             },
289         )
290         return NotificationGuts(notification)
291     }
292 
293     /** Clicks the notification and verifies that the expected app opens. */
294     fun clickToApp() {
295         Gestures.click(notification, "Notification")
296         verifyStartedApp()
297     }
298 
299     /** Clicks the notification to open the bouncer. */
300     fun clickToBouncer(): Bouncer {
301         assertWithMessage("The notification should be a lockscreen one")
302             .that(fromLockscreen)
303             .isTrue()
304         Gestures.click(notification, "Notification")
305         return Bouncer(/* notification= */ this)
306     }
307 
308     fun verifyStartedApp() {
309         check(
310             uiDevice.wait(Until.hasObject(By.pkg(pkg!!).depth(0)), LAUNCH_APP_TIMEOUT.toMillis())
311         ) {
312             "Did not find application, $pkg, in foreground"
313         }
314     }
315 
316     /** Clicks "show bubble" button to show a bubble. */
317     fun showBubble() {
318         // Create bubble from the notification
319         TaplUiDevice.waitForObject(BUBBLE_BUTTON_SELECTOR, "Show bubble button").click()
320 
321         // Verify that a bubble is visible
322         Root.get().bubble
323     }
324 
325     /** Clicks the "show bubble" button to show a bubble bar bubble. */
326     fun showBubbleBarBubble() {
327         // Create bubble from the notification
328         TaplUiDevice.waitForObject(BUBBLE_BUTTON_SELECTOR, "Show bubble button").click()
329 
330         // Verify that a bubble is visible
331         Root.get().bubbleBar
332     }
333 
334     /** Taps the snooze button on the notification */
335     fun snooze(): Notification = also {
336         ensureThat { notification.isLongClickable }
337 
338         val snoozeButton = notification.waitForObj(SNOOZE_BUTTON_SELECTOR)
339 
340         Gestures.click(snoozeButton, "Snooze button")
341 
342         notification.assertVisibility(UNDO_BUTTON_SELECTOR, true)
343 
344         ensureThat { !notification.isLongClickable }
345     }
346 
347     /** Taps undo button on the snoozed notification */
348     fun unsnooze(): Notification = also {
349         ensureThat { !notification.isLongClickable }
350 
351         val undoButton = notification.waitForObj(UNDO_BUTTON_SELECTOR)
352 
353         Gestures.click(undoButton, "Undo Snooze button")
354 
355         notification.assertVisibility(SNOOZE_BUTTON_SELECTOR, true)
356 
357         ensureThat { notification.isLongClickable }
358     }
359 
360     /** Verifies that the given notification action is enabled/disabled */
361     fun verifyActionIsEnabled(actionSelectorText: String, expectedEnabledState: Boolean) {
362         val actionButton =
363             notification.wait(
364                 Until.findObject(By.text(actionSelectorText)),
365                 UI_RESPONSE_TIMEOUT.toMillis(),
366             )
367 
368         assertThat(actionButton).isNotNull()
369 
370         ensureThat { actionButton.isEnabled == expectedEnabledState }
371     }
372 
373     fun verifyTitleEquals(expected: String) {
374         waitForObj(
375             By.copy(TITLE_SELECTOR).text(expected),
376             errorProvider = { "Couldn't find title with text \"$expected\"" },
377         )
378     }
379 
380     fun verifyBigTextEquals(expected: String) {
381         waitForObj(
382             By.copy(BIG_TEXT_SELECTOR).text(expected),
383             errorProvider = { "Couldn't find big text with text \"$expected\"" },
384         )
385     }
386 
387     fun clickButton(label: String) {
388         notification.waitForObj(By.text(label)).click()
389     }
390 
391     /**
392      * Press the reply button, enter [text] to reply with and send.
393      *
394      * NOTE: Prefer using shorter strings here, as longer ones tend to have a significant effect on
395      * performance on slower test devices.
396      */
397     fun replyWithText(text: String) {
398         // This sometimes has issues where it can't find the reply button due to a
399         // StaleObjectException, although the button is there. So we attempt each interaciton
400         // three times, separately.
401         retryIfStale(description = "find reply button", times = 3) {
402             notification.waitForObj(REPLY_BUTTON_SELECTOR, SHORT_WAIT).click()
403         }
404 
405         var remoteInputSelector: UiObject2? = null
406         retryIfStale(description = "add reply text \"$text\"", times = 3) {
407             remoteInputSelector = notification.waitForObj(REMOTE_INPUT_TEXT_SELECTOR, LONG_WAIT)
408         }
409         if (remoteInputSelector == null) {
410             // If the screen is too small, it might be hidden by IME.
411             // Dismiss the IME and try again.
412             getUiDevice().pressBack()
413             retryIfStale(description = "add reply text \"$text\"", times = 3) {
414                 remoteInputSelector = notification.waitForObj(REMOTE_INPUT_TEXT_SELECTOR, LONG_WAIT)
415             }
416         }
417         remoteInputSelector?.text = text
418 
419         var sendSelector: UiObject2? = null
420         retryIfStale(description = "find send selector input", times = 3) {
421             sendSelector = notification.waitForObj(REMOTE_INPUT_SEND_SELECTOR, SHORT_WAIT)
422         }
423         if (sendSelector == null) {
424             // If the screen is too small, it might be hidden by IME.
425             // Dismiss the IME and try again.
426             getUiDevice().pressBack()
427             retryIfStale(description = "find send selector input", times = 3) {
428                 sendSelector = notification.waitForObj(REMOTE_INPUT_SEND_SELECTOR, SHORT_WAIT)
429             }
430         }
431         sendSelector?.click()
432     }
433 
434     fun assertReplyHistoryContains(reply: String) {
435         ensureThat("Reply history should contain \"$reply\"") { replyHistoryContains(reply) }
436     }
437 
438     fun getUiObject(): UiObject2 = notification
439 
440     private fun replyHistoryContains(reply: String): Boolean {
441         // Fail if we cannot find the container
442         val container = notification.waitForObj(REPLY_HISTORY_CONTAINER, LONG_WAIT)
443         return (1..3).any { i ->
444             val replyObject =
445                 container.waitForNullableObj(getReplyHistorySelector(i), SHORT_WAIT)
446                     ?: return false // We don't expect more replies
447             replyObject.text == reply
448         }
449     }
450 
451     private fun swipeRightOnNotification() {
452         val bounds = notification.visibleBounds
453         val centerY = (bounds.top + bounds.bottom) / 2f
454         BetterSwipe.swipe(
455             PointF(bounds.left.toFloat(), centerY),
456             PointF(bounds.right.toFloat(), centerY),
457         )
458     }
459 
460     companion object {
461         private const val APP_NAME = "Scenario"
462         private val UI_RESPONSE_TIMEOUT = Duration.ofSeconds(3)
463         private val LAUNCH_APP_TIMEOUT = Duration.ofSeconds(10)
464         private val SHORT_TRANSITION_WAIT = Duration.ofMillis(1500)
465         private val TIMEOUT_MS = LONG_WAIT.toMillis()
466 
467         private val TITLE_SELECTOR = androidResSelector("title")
468         private val MESSAGE_SELECTOR = androidResSelector("group_message_container")
469         private val COLLAPSE_SELECTOR = By.descContains("Collapse")
470         private val GUTS_SELECTOR = sysuiResSelector("notification_guts").maxDepth(1)
471         private const val NOTIFICATION_CHANNEL_NAME = "Test Channel DEFAULT_IMPORTANCE"
472         private val GUTS_SETTINGS_SELECTOR = sysuiResSelector("info")
473         private val GUTS_CLOSE_SELECTOR = sysuiResSelector("done")
474         private val BUBBLE_BUTTON_SELECTOR = By.res("android:id/bubble_button")
475         private val SNOOZE_BUTTON_SELECTOR = androidResSelector("snooze_button")
476         private val UNDO_BUTTON_SELECTOR = By.text("Undo")
477         private val ACTION_BUTTON_SELECTOR = androidResSelector("action0")
478         private val BIG_TEXT_SELECTOR = androidResSelector("big_text")
479 
480         // RemoteInput selectors
481         private val REPLY_BUTTON_SELECTOR = androidResSelector("action0").descContains("Reply")
482         private val REMOTE_INPUT_TEXT_SELECTOR = sysuiResSelector("remote_input_text")
483         private val REMOTE_INPUT_SEND_SELECTOR = sysuiResSelector("remote_input_send")
484         private val REPLY_HISTORY_CONTAINER =
485             androidResSelector("notification_material_reply_container")
486 
487         private fun getReplyHistorySelector(index: Int) =
488             androidResSelector("notification_material_reply_text_$index")
489 
490         const val MAX_FIND_BOTTOM_ATTEMPTS = 15
491 
492         const val NOTIFICATION_TITLE_TEXT = "TEST NOTIFICATION"
493 
494         @JvmField
495         val NOTIFICATION_BIG_TEXT =
496             """
497             lorem ipsum dolor sit amet
498             lorem ipsum dolor sit amet
499             lorem ipsum dolor sit amet
500             lorem ipsum dolor sit amet
501             """
502                 .trimIndent()
503         private const val EXPAND_BUTTON_ID = "expand_button"
504         private const val APP_ICON_ID = "icon"
505         private const val NOTIFICATION_CONTENT_TEXT = "Test notification content"
506 
507         private val expandableNotificationRows: List<UiObject2>
508             get() {
509                 return uiDevice.wait(
510                     Until.findObjects(NOTIFICATION_ROW_SELECTOR),
511                     SHORT_TRANSITION_WAIT.toMillis(),
512                 ) ?: emptyList()
513             }
514     }
515 }
516