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