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