Rewrite "waitForObject" for notification UI

Tests consistently fail due to standard scrolling assumptions not being
applicable to the notification UI. We address this by writing our own
version of waitForObject, waitForNotification, that deals with these
issues. Namely

- Instead of ANY scrollable, it only scrolls the notification list (as
identified by the resource id)
- The end of the list is determined by when the "Clear all" button
appears (the standard way does not work as it continues to scroll until
the notification panel is closed)
- Resetting to the top is done by closing and opening the notification
list again

We also move a lot of common utility to AppHibernationUtils to be used
by both AutoRevokeTest and AppHibernationIntegrationTest

Bug: 191506099
Test: atest AutoRevokeTest
Test: atest AppHibernationIntegrationTest
Test: forrest runs
Change-Id: I99bb2eca81f40835ef279ddd714574c24ed8776f
diff --git a/tests/tests/os/src/android/os/cts/AppHibernationIntegrationTest.kt b/tests/tests/os/src/android/os/cts/AppHibernationIntegrationTest.kt
index a7e893d..e2718df 100644
--- a/tests/tests/os/src/android/os/cts/AppHibernationIntegrationTest.kt
+++ b/tests/tests/os/src/android/os/cts/AppHibernationIntegrationTest.kt
@@ -21,8 +21,8 @@
 import android.content.Intent
 import android.content.pm.ApplicationInfo
 import android.content.pm.PackageManager
-import android.os.Build
 import android.net.Uri
+import android.os.Build
 import android.platform.test.annotations.AppModeFull
 import android.provider.DeviceConfig.NAMESPACE_APP_HIBERNATION
 import android.provider.Settings
@@ -114,9 +114,8 @@
                         packageManager.getApplicationInfo(APK_PACKAGE_NAME_S_APP, 0 /* flags */)
                     val stopped = ((ai.flags and ApplicationInfo.FLAG_STOPPED) != 0)
                     assertTrue(stopped)
-                    runShellCommandOrThrow("cmd statusbar expand-notifications")
-                    waitFindObject(By.textContains("unused app"))
-                        .click()
+                    openUnusedAppsNotification()
+
                     waitFindObject(By.text(APK_PACKAGE_NAME_S_APP))
                 }
             }
diff --git a/tests/tests/os/src/android/os/cts/AppHibernationUtils.kt b/tests/tests/os/src/android/os/cts/AppHibernationUtils.kt
index faae778..da55409 100644
--- a/tests/tests/os/src/android/os/cts/AppHibernationUtils.kt
+++ b/tests/tests/os/src/android/os/cts/AppHibernationUtils.kt
@@ -21,26 +21,46 @@
 import android.app.Instrumentation
 import android.app.UiAutomation
 import android.content.Context
+import android.content.pm.PackageManager
 import android.os.ParcelFileDescriptor
 import android.os.Process
 import android.provider.DeviceConfig
+import android.support.test.uiautomator.By
 import android.support.test.uiautomator.BySelector
+import android.support.test.uiautomator.UiDevice
 import android.support.test.uiautomator.UiObject2
+import android.support.test.uiautomator.UiScrollable
+import android.support.test.uiautomator.UiSelector
+import android.support.test.uiautomator.Until
 import androidx.test.InstrumentationRegistry
+import com.android.compatibility.common.util.ExceptionUtils.wrappingExceptions
 import com.android.compatibility.common.util.LogcatInspector
 import com.android.compatibility.common.util.SystemUtil.eventually
 import com.android.compatibility.common.util.SystemUtil.runShellCommandOrThrow
 import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity
 import com.android.compatibility.common.util.ThrowingSupplier
 import com.android.compatibility.common.util.UiAutomatorUtils
+import com.android.compatibility.common.util.UiDumpUtils
 import com.android.compatibility.common.util.click
 import com.android.compatibility.common.util.depthFirstSearch
 import com.android.compatibility.common.util.textAsString
 import org.hamcrest.Matcher
 import org.hamcrest.Matchers
+import org.junit.Assert
 import org.junit.Assert.assertThat
 import java.io.InputStream
 
+const val SYSUI_PKG_NAME = "com.android.systemui"
+const val NOTIF_LIST_ID = "com.android.systemui:id/notification_stack_scroller"
+const val CLEAR_ALL_BUTTON_ID = "dismiss_text"
+// Time to find a notification. Unlikely, but in cases with a lot of notifications, it may take
+// time to find the notification we're looking for
+const val NOTIF_FIND_TIMEOUT = 20000L
+const val VIEW_WAIT_TIMEOUT = 1000L
+
+const val CMD_EXPAND_NOTIFICATIONS = "cmd statusbar expand-notifications"
+const val CMD_COLLAPSE = "cmd statusbar collapse"
+
 const val APK_PATH_S_APP = "/data/local/tmp/cts/os/CtsAutoRevokeSApp.apk"
 const val APK_PACKAGE_NAME_S_APP = "android.os.cts.autorevokesapp"
 const val APK_PATH_R_APP = "/data/local/tmp/cts/os/CtsAutoRevokeRApp.apk"
@@ -148,6 +168,82 @@
     runShellCommandOrThrow("input keyevent KEYCODE_HOME")
 }
 
+/**
+ * Open the "unused apps" notification which is sent after the hibernation job.
+ */
+fun openUnusedAppsNotification() {
+    val notifSelector = By.textContains("unused app")
+    if (hasFeatureWatch()) {
+        val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+        expandNotificationsWatch(UiAutomatorUtils.getUiDevice())
+        waitFindObject(uiAutomation, notifSelector).click()
+        // In wear os, notification has one additional button to open it
+        waitFindObject(uiAutomation, By.text("Open")).click()
+    } else {
+        runShellCommandOrThrow(CMD_EXPAND_NOTIFICATIONS)
+        waitFindNotification(notifSelector, NOTIF_FIND_TIMEOUT).click()
+    }
+}
+
+fun hasFeatureWatch(): Boolean {
+    return InstrumentationRegistry.getTargetContext().packageManager.hasSystemFeature(
+        PackageManager.FEATURE_WATCH)
+}
+
+private fun expandNotificationsWatch(uiDevice: UiDevice) {
+    with(uiDevice) {
+        wakeUp()
+        // Swipe up from bottom to reveal notifications
+        val x = displayWidth / 2
+        swipe(x, displayHeight, x, 0, 1)
+    }
+}
+
+/**
+ * Reset to the top of the notifications list.
+ */
+private fun resetNotifications(notificationList: UiScrollable) {
+    runShellCommandOrThrow(CMD_COLLAPSE)
+    notificationList.waitUntilGone(VIEW_WAIT_TIMEOUT)
+    runShellCommandOrThrow(CMD_EXPAND_NOTIFICATIONS)
+}
+
+private fun waitFindNotification(selector: BySelector, timeoutMs: Long):
+    UiObject2 {
+    var view: UiObject2? = null
+    val start = System.currentTimeMillis()
+    val uiDevice = UiAutomatorUtils.getUiDevice()
+
+    var isAtEnd = false
+    var wasScrolledUpAlready = false
+    while (view == null && start + timeoutMs > System.currentTimeMillis()) {
+        view = uiDevice.wait(Until.findObject(selector), VIEW_WAIT_TIMEOUT)
+        if (view == null) {
+            val notificationList = UiScrollable(UiSelector().resourceId(NOTIF_LIST_ID))
+            wrappingExceptions({ cause: Throwable? -> UiDumpUtils.wrapWithUiDump(cause) }) {
+                Assert.assertTrue("Notification list view not found",
+                    notificationList.waitForExists(VIEW_WAIT_TIMEOUT))
+            }
+            if (isAtEnd) {
+                if (wasScrolledUpAlready) {
+                    break
+                }
+                resetNotifications(notificationList)
+                isAtEnd = false
+                wasScrolledUpAlready = true
+            } else {
+                notificationList.scrollForward()
+                isAtEnd = uiDevice.hasObject(By.res(SYSUI_PKG_NAME, CLEAR_ALL_BUTTON_ID))
+            }
+        }
+    }
+    wrappingExceptions({ cause: Throwable? -> UiDumpUtils.wrapWithUiDump(cause) }) {
+        Assert.assertNotNull("View not found after waiting for " + timeoutMs + "ms: " + selector,
+            view)
+    }
+    return view!!
+}
+
 fun waitFindObject(uiAutomation: UiAutomation, selector: BySelector): UiObject2 {
     try {
         return UiAutomatorUtils.waitFindObject(selector)
diff --git a/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt b/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt
index e17883c..d5cc564 100644
--- a/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt
+++ b/tests/tests/os/src/android/os/cts/AutoRevokeTest.kt
@@ -31,7 +31,6 @@
 import android.platform.test.annotations.AppModeFull
 import android.support.test.uiautomator.By
 import android.support.test.uiautomator.BySelector
-import android.support.test.uiautomator.UiDevice
 import android.support.test.uiautomator.UiObject2
 import android.support.test.uiautomator.UiObjectNotFoundException
 import android.view.accessibility.AccessibilityNodeInfo
@@ -84,7 +83,6 @@
 
     private val context: Context = InstrumentationRegistry.getTargetContext()
     private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
-    private val uiDevice: UiDevice = UiDevice.getInstance(instrumentation)
 
     private val mPermissionControllerResources: Resources = context.createPackageContext(
             context.packageManager.permissionControllerPackageName, 0).resources
@@ -392,19 +390,6 @@
         assertPermission(PERMISSION_GRANTED)
     }
 
-    private fun openUnusedAppsNotification() {
-        if (hasFeatureWatch()) {
-            expandNotificationsWatch()
-        } else {
-            runShellCommandOrThrow("cmd statusbar expand-notifications")
-        }
-        waitFindObject(By.textContains("unused app")).click()
-        if (hasFeatureWatch()) {
-            // In wear os, notification has one additional button to open it
-            waitFindObject(By.text("Open")).click()
-        }
-    }
-
     private fun goBack() {
         runShellCommandOrThrow("input keyevent KEYCODE_BACK")
     }
@@ -500,19 +485,6 @@
         waitForIdle()
     }
 
-    private fun hasFeatureWatch(): Boolean {
-        return context.packageManager.hasSystemFeature(PackageManager.FEATURE_WATCH)
-    }
-
-    private fun expandNotificationsWatch() {
-        with(uiDevice) {
-            wakeUp()
-            // Swipe up from bottom to reveal notifications
-            val x = displayWidth / 2
-            swipe(x, displayHeight, x, 0, 1)
-        }
-    }
-
     private fun assertAllowlistState(state: Boolean) {
         assertThat(
             waitFindObject(By.textStartsWith("Auto-revoke allowlisted: ")).text,