xref: /aosp_15_r20/platform_testing/libraries/systemui-tapl/src/android/platform/systemui_tapl/ui/Root.kt (revision dd0948b35e70be4c0246aabd6c72554a5eb8b22a)
1 /*
2  * 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.Point
20 import android.graphics.PointF
21 import android.graphics.Rect
22 import android.os.RemoteException
23 import android.os.SystemClock
24 import android.platform.systemui_tapl.controller.LockscreenController
25 import android.platform.systemui_tapl.controller.NotificationIdentity
26 import android.platform.systemui_tapl.ui.ExpandedBubbleStack.Companion.BUBBLE_EXPANDED_VIEW
27 import android.platform.systemui_tapl.utils.DeviceUtils.LONG_WAIT
28 import android.platform.systemui_tapl.utils.DeviceUtils.sysuiResSelector
29 import android.platform.uiautomatorhelpers.BetterSwipe
30 import android.platform.uiautomatorhelpers.DeviceHelpers
31 import android.platform.uiautomatorhelpers.DeviceHelpers.assertInvisible
32 import android.platform.uiautomatorhelpers.DeviceHelpers.assertVisible
33 import android.platform.uiautomatorhelpers.DeviceHelpers.betterSwipe
34 import android.platform.uiautomatorhelpers.DeviceHelpers.uiDevice
35 import android.platform.uiautomatorhelpers.FLING_GESTURE_INTERPOLATOR
36 import android.platform.uiautomatorhelpers.TracingUtils.trace
37 import android.view.InputDevice
38 import android.view.InputEvent
39 import android.view.KeyCharacterMap
40 import android.view.KeyEvent
41 import android.view.WindowInsets
42 import android.view.WindowManager
43 import android.view.WindowMetrics
44 import androidx.test.platform.app.InstrumentationRegistry
45 import androidx.test.uiautomator.By
46 import androidx.test.uiautomator.UiSelector
47 import androidx.test.uiautomator.Until
48 import com.android.launcher3.tapl.LauncherInstrumentation
49 import com.android.launcher3.tapl.Workspace
50 import com.google.common.truth.Truth.assertThat
51 import java.time.Duration
52 import org.junit.Assert
53 
54 /**
55  * The root class for System UI test automation objects. All System UI test automation objects are
56  * produced by this class or other System UI test automation objects.
57  */
58 class Root private constructor() {
59 
60     /**
61      * Opens the notification shade. Use this if there is no need to assert the way of opening it.
62      *
63      * Uses AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS to open the shade, because it turned
64      * out to be more reliable than swipe gestures. Note that GLOBAL_ACTION_NOTIFICATIONS won't open
65      * notifications shade if the lockscreen screen is shown.
66      */
openNotificationShadenull67     fun openNotificationShade(): NotificationShade {
68         return openNotificationShadeViaGlobalAction()
69     }
70 
71     /** Opens the notification shade via AccessibilityService.GLOBAL_ACTION_NOTIFICATIONS. */
openNotificationShadeViaGlobalActionnull72     fun openNotificationShadeViaGlobalAction(): NotificationShade {
73         trace("Opening notification shade via global action") {
74             uiDevice.openNotification()
75             waitForShadeToOpen()
76             return NotificationShade()
77         }
78     }
79 
80     /** Opens the notification shade via two fingers wipe. */
openNotificationShadeViaTwoFingersSwipenull81     fun openNotificationShadeViaTwoFingersSwipe(): NotificationShade {
82         return openNotificationShadeViaTwoFingersSwipe(Duration.ofMillis(300))
83     }
84 
85     /** Opens the notification shade via slow swipe. */
openNotificationShadeViaSlowSwipenull86     fun openNotificationShadeViaSlowSwipe(): NotificationShade {
87         return openNotificationShadeViaSwipe(Duration.ofMillis(3000))
88     }
89 
90     /**
91      * Opens the notification shade via swipe with a default speed of 500ms and default start point
92      * of 10% of the display height. NOTE: with b/277063189, the default start point of a quarter of
93      * the way down the screen can overlap a widget and the shade won't open.
94      *
95      * @param swipeDuration amount of time the swipe will last from start to finish
96      * @param heightFraction fraction of the height of the display to start from.
97      */
98     @JvmOverloads
openNotificationShadeViaSwipenull99     fun openNotificationShadeViaSwipe(
100         swipeDuration: Duration = Duration.ofMillis(500),
101         heightFraction: Float = 0.1F,
102     ): NotificationShade {
103         trace("Opening notification shade via swipe") {
104             val device = uiDevice
105             val width = device.displayWidth.toFloat()
106             val height = device.displayHeight.toFloat()
107             BetterSwipe.from(PointF(width / 2, height * heightFraction))
108                 .to(PointF(width / 2, height), swipeDuration, FLING_GESTURE_INTERPOLATOR)
109                 .release()
110             waitForShadeToOpen()
111             return NotificationShade()
112         }
113     }
114 
115     /**
116      * Opens the notification shade via swipe from top of screen. Needed for opening shade while in
117      * an app.
118      */
openNotificationShadeViaSwipeFromTopnull119     fun openNotificationShadeViaSwipeFromTop(): NotificationShade {
120         val device = uiDevice
121         // Swipe in first quarter to avoid desktop windowing app handle interactions.
122         val swipeXCoordinate = (device.displayWidth / 4).toFloat()
123         val height = device.displayHeight.toFloat()
124         BetterSwipe.from(PointF(swipeXCoordinate, 0f))
125             .to(
126                 PointF(swipeXCoordinate, height),
127                 Duration.ofMillis(500),
128                 FLING_GESTURE_INTERPOLATOR,
129             )
130             .release()
131         waitForShadeToOpen()
132         return NotificationShade()
133     }
134 
135     /** Opens the notification shade via swipe. */
openNotificationShadeViaTwoFingersSwipenull136     private fun openNotificationShadeViaTwoFingersSwipe(
137         swipeDuration: Duration
138     ): NotificationShade {
139         val device = uiDevice
140         val width = device.displayWidth
141         val distance = device.displayHeight / 3 * 2
142         // Steps are injected about 5 milliseconds apart
143         val steps = swipeDuration.toMillisPart() / 5
144         val resId = "com.google.android.apps.nexuslauncher:id/workspace"
145         // Wait is only available for UiObject2
146         DeviceHelpers.waitForObj(By.res(resId))
147         val obj = device.findObject(UiSelector().resourceId(resId))
148         obj.performTwoPointerGesture(
149             Point(width / 3, 0),
150             Point(width / 3 * 2, 0),
151             Point(width / 3, distance),
152             Point(width / 3 * 2, distance),
153             steps,
154         )
155         waitForShadeToOpen()
156         return NotificationShade()
157     }
158 
159     /**
160      * Finds a HUN by its identity. Fails if the notification can't be found.
161      *
162      * @param identity The NotificationIdentity used to find the HUN
163      * @param assertIsHunState When it's true, findHeadsUpNotification would fail if the
164      *   notification is not at the HUN state (eg. showing in the Shade), or its HUN state cannot be
165      *   verified. An action button is necessary for the verification. Consider posting the HUN with
166      *   NotificationController#postBigTextHeadsUpNotification if you need to assert the HUN state.
167      *   Expanded HUN state cannot be asserted.
168      */
169     @JvmOverloads
findHeadsUpNotificationnull170     fun findHeadsUpNotification(
171         identity: NotificationIdentity,
172         assertIsHunState: Boolean = true,
173     ): Notification {
174         return NotificationStack.findHeadsUpNotification(
175             identity = identity,
176             assertIsHunState = assertIsHunState,
177         )
178     }
179 
180     /**
181      * Ensures there is not a HUN with this identity. Fails if the HUN is found, or the identity
182      * doesn't have an action button.
183      *
184      * @param identity The NotificationIdentity used to find the HUN, an action button is necessary
185      */
186     // TODO(b/295209746): More robust (and more performant) assertion for "HUN does not appear"
ensureNoHeadsUpNotificationnull187     fun ensureNoHeadsUpNotification(identity: NotificationIdentity) {
188         Assert.assertTrue(
189             "HUN state Assertion usage error: Notification: ${identity.title} " +
190                 "| You can only assert the HUN State of a notification that has an action " +
191                 "button.",
192             identity.hasAction,
193         )
194         Assert.assertThrows(IllegalStateException::class.java) {
195             findHeadsUpNotification(identity, assertIsHunState = false)
196         }
197     }
198 
199     /** Opens the quick settings via AccessibilityService.GLOBAL_ACTION_QUICK_SETTINGS. */
openQuickSettingsViaGlobalActionnull200     fun openQuickSettingsViaGlobalAction(): QuickSettings {
201         val device = uiDevice
202         device.openQuickSettings()
203         // Quick Settings isn't always open when this is complete. Explicitly wait for the Quick
204         // Settings footer to make sure that the buttons are accessible when the bar is open and
205         // this call is complete.
206         FOOTER_SELECTOR.assertVisible()
207         // Wait an extra bit for the animation to complete. If we return to early, future callers
208         // that are trying to find the location of the footer will get incorrect coordinates
209         device.waitForIdle(LONG_TIMEOUT.toLong())
210         return QuickSettings()
211     }
212 
213     /** Gets status bar. */
214     val statusBar: StatusBar
215         get() = StatusBar()
216 
217     /** Gets an alert dialog. */
218     val alertDialog: AlertDialog
219         get() = AlertDialog()
220 
221     /** Gets a media projection permission dialog. */
222     val mediaProjectionPermissionDialog: MediaProjectionPermissionDialog
223         get() = MediaProjectionPermissionDialog()
224 
225     /** Gets a media projection app selector. */
226     val mediaProjectionAppSelector: MediaProjectionAppSelector
227         get() = MediaProjectionAppSelector()
228 
229     /** Asserts that the media projection permission dialog is not visible. */
assertMediaProjectionPermissionDialogNotVisiblenull230     fun assertMediaProjectionPermissionDialogNotVisible() {
231         MediaProjectionPermissionDialog.assertSpinnerVisibility(false)
232     }
233 
234     /** Gets lock screen. Fails if lock screen is not visible. */
235     val lockScreen: LockScreen
236         get() = LockScreen()
237 
238     /** Gets primary bouncer. Fails if the primary bouncer is not visible. */
239     val primaryBouncer: Bouncer
240         get() = Bouncer(null)
241 
242     /** Gets Aod. Fails if Aod is not visible. */
243     val aod: Aod
244         get() = Aod()
245 
246     /** Gets ChooseScreenLock. Fails if ChooseScreenLock is not visible. */
247     val chooseScreenLock: ChooseScreenLock
248         get() = ChooseScreenLock()
249 
250     /** Gets the bubble. Fails if there is no bubble. */
251     val bubble: Bubble
252         get() {
253             val bubbleViews = Bubble.bubbleViews
254             return Bubble(bubbleViews[0])
255         }
256 
257     /**
258      * Returns the selected bubble.
259      *
260      * Bubbles in the collapsed stack are reversed. The selected bubble is the last bubble in the
261      * view hierarchy.
262      */
263     val selectedBubble: Bubble
264         get() {
265             val bubbleViews = Bubble.bubbleViews
266             return Bubble(bubbleViews.last())
267         }
268 
269     /** Gets the expanded bubble stack. Fails if no stack or if the stack is not expanded. */
270     val expandedBubbleStack: ExpandedBubbleStack
271         get() = ExpandedBubbleStack()
272 
273     /** Gets the collapsed bubble bar in launcher. */
274     val bubbleBar: BubbleBar
275         get() = BubbleBar()
276 
277     /** Verifies that the bubble bar is hidden. */
verifyBubbleBarIsHiddennull278     fun verifyBubbleBarIsHidden() {
279         BubbleBar.BUBBLE_BAR_VIEW.assertInvisible(LONG_WAIT)
280     }
281 
282     /** Verifies that no bubbles or an expanded bubble stack are visible. */
verifyNoBubbleIsVisiblenull283     fun verifyNoBubbleIsVisible() {
284         Bubble.BUBBLE_VIEW.assertInvisible(timeout = Bubble.FIND_OBJECT_TIMEOUT)
285         verifyNoExpandedBubbleStackIsVisible()
286     }
287 
288     /** Verifies that expanded bubble stack is not visible. */
verifyNoExpandedBubbleStackIsVisiblenull289     fun verifyNoExpandedBubbleStackIsVisible() {
290         BUBBLE_EXPANDED_VIEW.assertInvisible(timeout = Bubble.FIND_OBJECT_TIMEOUT)
291     }
292 
293     /** Verifies that status bar is hidden by checking StatusBar's clock icon whether it exists. */
verifyStatusBarIsHiddennull294     fun verifyStatusBarIsHidden() {
295         assertThat(
296                 uiDevice.wait(
297                     Until.gone(sysuiResSelector(StatusBar.CLOCK_ID)),
298                     SHORT_TIMEOUT.toLong(),
299                 )
300             )
301             .isTrue()
302     }
303 
304     /** Takes a screenshot and returns the actions panel that appears. */
screenshotnull305     fun screenshot(): ScreenshotActions {
306         val device = uiDevice
307         device.pressKeyCode(KeyEvent.KEYCODE_SYSRQ)
308         check(
309             device.wait(Until.hasObject(GLOBAL_SCREENSHOT_SELECTOR), SCREENSHOT_POST_TIMEOUT_MSEC)
310         ) {
311             "Can't find screenshot image"
312         }
313         return ScreenshotActions()
314     }
315 
316     /** Gets the power panel. Fails if there is no power panel visible. */
317     val powerPanel: PowerPanel
318         get() = PowerPanel()
319 
320     /**
321      * Goes to Launcher workspace by sending KeyEvent.KEYCODE_HOME. This method is not
322      * representative of real user's actions, but it's more stable than
323      * LauncherInstrumentation.goHome because LauncherInstrumentation.goHome expects all prior
324      * animations to settle before it's used, which is true for Launcher tests that use it, but not
325      * necessarily true for SysUI tests.
326      *
327      * @return the Workspace object.
328      */
goHomeViaKeycodenull329     fun goHomeViaKeycode(): Workspace {
330         uiDevice.pressHome()
331         // getWorkspace will check `expectedRotation` and fail if it doesn't match the one from
332         // the device. However, if the test has an Orientation annotation, the orientation won't
333         // be fixed back until after this is run, possibly failing the test.
334         val instrumentation = LauncherInstrumentation()
335         instrumentation.setExpectedRotation(uiDevice.displayRotation)
336         return instrumentation.getWorkspace()
337     }
338 
wakeUpnull339     private fun wakeUp() {
340         try {
341             uiDevice.wakeUp()
342         } catch (e: RemoteException) {
343             e.printStackTrace()
344         }
345     }
346 
347     /** Returns the volume dialog or fails if it's invisible. */
348     val volumeDialog: VolumeDialog
349         get() = VolumeDialog()
350 
351     /** Asserts that the volume dialog is not visible. */
assertVolumeDialogNotVisiblenull352     fun assertVolumeDialogNotVisible() {
353         VolumeDialog.PAGE_TITLE_SELECTOR.assertInvisible()
354     }
355 
356     /** Asserts that lock screen is invisible. */
assertLockScreenNotVisiblenull357     fun assertLockScreenNotVisible() {
358         LockScreen.LOCKSCREEN_SELECTOR.assertInvisible()
359     }
360 
361     // TODO (b/277105514): Determine whether this is an idiomatic method of determing visibility.
362     /** Asserts that launcher is visible. */
assertLauncherVisiblenull363     fun assertLauncherVisible() {
364         By.pkg("com.google.android.apps.nexuslauncher").assertVisible()
365     }
366 
367     val keyboardBacklightIndicatorDialog: KeyboardBacklightIndicatorDialog
368         get() = KeyboardBacklightIndicatorDialog()
369 
assertKeyboardBacklightIndicatorDialogNotVisiblenull370     fun assertKeyboardBacklightIndicatorDialogNotVisible() {
371         KeyboardBacklightIndicatorDialog.CONTAINER_SELECTOR.assertInvisible()
372     }
373 
injectEventSyncnull374     private fun injectEventSync(event: InputEvent): Boolean {
375         return InstrumentationRegistry.getInstrumentation()
376             .uiAutomation
377             .injectInputEvent(event, true)
378     }
379 
sendKeynull380     private fun sendKey(keyCode: Int, metaState: Int, eventTime: Long): Boolean {
381         val downEvent =
382             KeyEvent(
383                 eventTime,
384                 eventTime,
385                 KeyEvent.ACTION_DOWN,
386                 keyCode,
387                 0,
388                 metaState,
389                 KeyCharacterMap.VIRTUAL_KEYBOARD,
390                 0,
391                 0,
392                 InputDevice.SOURCE_KEYBOARD,
393             )
394         if (injectEventSync(downEvent)) {
395             val upEvent =
396                 KeyEvent(
397                     eventTime,
398                     eventTime,
399                     KeyEvent.ACTION_UP,
400                     keyCode,
401                     0,
402                     metaState,
403                     KeyCharacterMap.VIRTUAL_KEYBOARD,
404                     0,
405                     0,
406                     InputDevice.SOURCE_KEYBOARD,
407                 )
408             if (injectEventSync(upEvent)) {
409                 return true
410             }
411         }
412         return false
413     }
414 
pressKeyCodenull415     private fun pressKeyCode(keyCode: Int, eventTime: Long) {
416         sendKey(keyCode, /* metaState= */ 0, eventTime)
417     }
418 
419     /** Double-taps the power button. Can be used to bring up the camera app. */
doubleTapPowerButtonnull420     fun doubleTapPowerButton() {
421         val eventTime = SystemClock.uptimeMillis()
422         pressKeyCode(KeyEvent.KEYCODE_POWER, eventTime)
423         pressKeyCode(KeyEvent.KEYCODE_POWER, eventTime + 1)
424     }
425 
426     /** Opens the tutorial by swiping. */
openTutorialViaSwipenull427     fun openTutorialViaSwipe(): OneHandModeTutorial {
428         NotificationShade.waitForShadeToClose()
429         val windowMetrics: WindowMetrics =
430             DeviceHelpers.context
431                 .getSystemService(WindowManager::class.java)!!
432                 .getCurrentWindowMetrics()
433         val insets: WindowInsets = windowMetrics.getWindowInsets()
434         val displayBounds: Rect = windowMetrics.getBounds()
435         val bottomMandatoryGestureHeight: Int =
436             insets
437                 .getInsetsIgnoringVisibility(
438                     WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout()
439                 )
440                 .bottom
441         NotificationShade.waitForShadeToClose()
442         uiDevice.betterSwipe(
443             displayBounds.width() / 2,
444             displayBounds.height() - Math.round(bottomMandatoryGestureHeight * 2.5f),
445             displayBounds.width() / 2,
446             displayBounds.height(),
447         )
448         NotificationShade.waitForShadeToClose()
449         return OneHandModeTutorial()
450     }
451 
452     /**
453      * Turn the device off and on, and check for the lockScreen. This should be used instead of
454      * LockscreenUtils.goToLockScreen() because LockscreenController validates that the screen is
455      * off or on, rather than just sleeping and waking up the device. "return lockScreen" calls the
456      * LockScreen constructor, which ensures that the lockscreen clock is visible
457      *
458      * TODO: replace LockscreenUtils.goToLockscreen() with this once it's submitted: b/322870306
459      */
goToLockscreennull460     fun goToLockscreen(): LockScreen {
461         LockscreenController.get().turnScreenOff()
462         LockscreenController.get().turnScreenOn()
463         return lockScreen
464     }
465 
466     companion object {
467         private val QS_HEADER_SELECTOR =
468             if (com.android.systemui.Flags.sceneContainer()) {
469                 sysuiResSelector("shade_header_root")
470             } else {
471                 sysuiResSelector("split_shade_status_bar")
472             }
473         private val NOTIFICATION_SHADE_OPEN_TIMEOUT = Duration.ofSeconds(20)
474         private const val LONG_TIMEOUT = 2000
475         private const val SHORT_TIMEOUT = 500
476         private val FOOTER_SELECTOR = sysuiResSelector("qs_footer_actions")
477         private const val SCREENSHOT_POST_TIMEOUT_MSEC: Long = 20000
478         private val GLOBAL_SCREENSHOT_SELECTOR = sysuiResSelector("screenshot_actions")
479 
480         /** Returns an instance of Root. */
481         @JvmStatic
getnull482         fun get(): Root {
483             return Root()
484         }
485 
waitForShadeToOpennull486         private fun waitForShadeToOpen() {
487             // Note that this duplicates the tracing done by assertVisible, but with a better name.
488             trace("waitForShadeToOpen") {
489                 QS_HEADER_SELECTOR.assertVisible(
490                     timeout = NOTIFICATION_SHADE_OPEN_TIMEOUT,
491                     errorProvider = { "Notification shade didn't open" },
492                 )
493             }
494         }
495     }
496 }
497