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