1 /*
2  * 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.spectatio.utils;
18 
19 import android.app.Instrumentation;
20 import android.graphics.Point;
21 import android.graphics.Rect;
22 import android.os.RemoteException;
23 import android.os.SystemClock;
24 import android.os.UserHandle;
25 import android.platform.spectatio.exceptions.MissingUiElementException;
26 import android.util.Log;
27 import android.view.KeyEvent;
28 import com.google.escapevelocity.Template;
29 
30 import androidx.test.uiautomator.By;
31 import androidx.test.uiautomator.BySelector;
32 import androidx.test.uiautomator.Direction;
33 import androidx.test.uiautomator.UiDevice;
34 import androidx.test.uiautomator.UiObject2;
35 import androidx.test.uiautomator.Until;
36 
37 import com.google.common.base.Strings;
38 
39 import java.io.ByteArrayOutputStream;
40 import java.io.ByteArrayInputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.io.InputStreamReader;
44 import java.nio.charset.StandardCharsets;
45 import java.util.ArrayList;
46 import java.util.HashMap;
47 import java.util.List;
48 import java.util.Locale;
49 import java.util.Map;
50 
51 public class SpectatioUiUtil {
52     private static final String LOG_TAG = SpectatioUiUtil.class.getSimpleName();
53 
54     private static SpectatioUiUtil sSpectatioUiUtil = null;
55 
56     private static final int SHORT_UI_RESPONSE_WAIT_MS = 1000;
57     private static final int LONG_UI_RESPONSE_WAIT_MS = 5000;
58     private static final int EXTRA_LONG_UI_RESPONSE_WAIT_MS = 15000;
59     private static final int LONG_PRESS_DURATION_MS = 5000;
60     private static final int MAX_SCROLL_COUNT = 100;
61     private static final int MAX_SWIPE_STEPS = 10;
62     private static final float SCROLL_PERCENT = 1.0f;
63     private static final float SWIPE_PERCENT = 1.0f;
64 
65     private int mWaitTimeAfterScroll = 5; // seconds
66     private int mScrollMargin = 4;
67 
68     private UiDevice mDevice;
69 
70     public enum SwipeDirection {
71         TOP_TO_BOTTOM,
72         BOTTOM_TO_TOP,
73         LEFT_TO_RIGHT,
74         RIGHT_TO_LEFT
75     }
76 
77     /**
78      * Defines the swipe fraction, allowing for a swipe to be performed from a 5-pad distance, a
79      * quarter, half, three-quarters of the screen, or the full screen.
80      *
81      * <p>DEFAULT: Swipe from one side of the screen to another side, with a 5-pad distance from the
82      * edge.
83      *
84      * <p>QUARTER: Swipe from one side, a quarter of the distance of the entire screen away from the
85      * edge, to the other side.
86      *
87      * <p>HALF: Swipe from the center of the screen to the other side.
88      *
89      * <p>THREEQUARTER: Swipe from one side, three-quarters of the distance of the entire screen
90      * away from the edge, to the other side.
91      *
92      * <p>FULL: Swipe from one edge of the screen to the other edge.
93      */
94     public enum SwipeFraction {
95         DEFAULT,
96         QUARTER,
97         HALF,
98         THREEQUARTER,
99         FULL,
100     }
101 
102     /**
103      * Defines the swipe speed based on the number of steps.
104      *
105      * <p><a
106      * href="https://developer.android.com/reference/androidx/test/uiautomator/UiDevice#swipe(int,int,int,int,int)">UiDevie#Swipe</a>
107      * performs a swipe from one coordinate to another using the number of steps to determine
108      * smoothness and speed. Each step execution is throttled to 5ms per step. So for a 100 steps,
109      * the swipe will take about 1/2 second to complete.
110      */
111     public enum SwipeSpeed {
112         NORMAL(200), // equals to 1000ms in duration.
113         SLOW(1000), // equals to 5000ms in duration.
114         FAST(50), // equals to 250ms in duration.
115         FLING(20); // equals to 100ms in duration.
116 
117         final int mNumSteps;
118 
SwipeSpeed(int numOfSteps)119         SwipeSpeed(int numOfSteps) {
120             this.mNumSteps = numOfSteps;
121         }
122     }
123 
SpectatioUiUtil(UiDevice mDevice)124     private SpectatioUiUtil(UiDevice mDevice) {
125         this.mDevice = mDevice;
126     }
127 
getInstance(UiDevice mDevice)128     public static SpectatioUiUtil getInstance(UiDevice mDevice) {
129         if (sSpectatioUiUtil == null) {
130             sSpectatioUiUtil = new SpectatioUiUtil(mDevice);
131         }
132         return sSpectatioUiUtil;
133     }
134 
135     /**
136      * Initialize a UiDevice for the given instrumentation, then initialize Spectatio for that
137      * device. If Spectatio has already been initialized, return the previously initialized
138      * instance.
139      */
getInstance(Instrumentation instrumentation)140     public static SpectatioUiUtil getInstance(Instrumentation instrumentation) {
141         return getInstance(UiDevice.getInstance(instrumentation));
142     }
143 
144     /** Sets the scroll margin and wait time after the scroll */
addScrollValues(Integer scrollMargin, Integer waitTime)145     public void addScrollValues(Integer scrollMargin, Integer waitTime) {
146         this.mScrollMargin = scrollMargin;
147         this.mWaitTimeAfterScroll = waitTime;
148     }
149 
pressBack()150     public boolean pressBack() {
151         return mDevice.pressBack();
152     }
153 
pressHome()154     public boolean pressHome() {
155         return mDevice.pressHome();
156     }
157 
pressKeyCode(int keyCode)158     public boolean pressKeyCode(int keyCode) {
159         return mDevice.pressKeyCode(keyCode);
160     }
161 
pressPower()162     public boolean pressPower() {
163         return pressKeyCode(KeyEvent.KEYCODE_POWER);
164     }
165 
longPress(UiObject2 uiObject)166     public boolean longPress(UiObject2 uiObject) {
167         if (!isValidUiObject(uiObject)) {
168             Log.e(
169                     LOG_TAG,
170                     "Cannot Long Press UI Object; Provide a valid UI Object, currently it is"
171                             + " NULL.");
172             return false;
173         }
174         if (!uiObject.isLongClickable()) {
175             Log.e(
176                     LOG_TAG,
177                     "Cannot Long Press UI Object; Provide a valid UI Object, "
178                             + "current UI Object is not long clickable.");
179             return false;
180         }
181         uiObject.longClick();
182         wait1Second();
183         return true;
184     }
185 
longPressKey(int keyCode)186     public boolean longPressKey(int keyCode) {
187         try {
188             // Use English Locale because ADB Shell command does not depend on Device UI
189             mDevice.executeShellCommand(
190                     String.format(Locale.ENGLISH, "input keyevent --longpress %d", keyCode));
191             wait1Second();
192             return true;
193         } catch (IOException e) {
194             // Ignore
195             Log.e(
196                     LOG_TAG,
197                     String.format(
198                             "Failed to long press key code: %d, Error: %s",
199                             keyCode, e.getMessage()));
200         }
201         return false;
202     }
203 
longPressPower()204     public boolean longPressPower() {
205         return longPressKey(KeyEvent.KEYCODE_POWER);
206     }
207 
longPressScreenCenter()208     public boolean longPressScreenCenter() {
209         Rect bounds = getScreenBounds();
210         int xCenter = bounds.centerX();
211         int yCenter = bounds.centerY();
212         try {
213             // Click method in UiDevice only takes x and y co-ordintes to tap,
214             // so it can be clicked but cannot be pressed for long time
215             // Use ADB command to Swipe instead (because UiDevice swipe method don't take duration)
216             // i.e. simulate long press by swiping from
217             // center of screen to center of screen (i.e. same points) for long duration
218             // Use English Locale because ADB Shell command does not depend on Device UI
219             mDevice.executeShellCommand(
220                     String.format(
221                             Locale.ENGLISH,
222                             "input swipe %d %d %d %d %d",
223                             xCenter,
224                             yCenter,
225                             xCenter,
226                             yCenter,
227                             LONG_PRESS_DURATION_MS));
228             wait1Second();
229             return true;
230         } catch (IOException e) {
231             // Ignore
232             Log.e(
233                     LOG_TAG,
234                     String.format(
235                             "Failed to long press on screen center. Error: %s", e.getMessage()));
236         }
237         return false;
238     }
239 
wakeUp()240     public void wakeUp() {
241         try {
242             mDevice.wakeUp();
243         } catch (RemoteException ex) {
244             throw new IllegalStateException("Failed to wake up device.", ex);
245         }
246     }
247 
clickAndWait(UiObject2 uiObject)248     public void clickAndWait(UiObject2 uiObject) {
249         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Click");
250         uiObject.click();
251         wait1Second();
252     }
253 
254     /**
255      * Click at a specific location in the UI, and wait one second
256      *
257      * @param location Where to click
258      */
clickAndWait(Point location)259     public void clickAndWait(Point location) {
260         mDevice.click(location.x, location.y);
261         wait1Second();
262     }
263 
waitForIdle()264     public void waitForIdle() {
265         mDevice.waitForIdle();
266     }
267 
wait1Second()268     public void wait1Second() {
269         waitNSeconds(SHORT_UI_RESPONSE_WAIT_MS);
270     }
271 
wait5Seconds()272     public void wait5Seconds() {
273         waitNSeconds(LONG_UI_RESPONSE_WAIT_MS);
274     }
275 
276     /** Waits for 15 seconds */
wait15Seconds()277     public void wait15Seconds() {
278         waitNSeconds(EXTRA_LONG_UI_RESPONSE_WAIT_MS);
279     }
280 
waitNSeconds(int waitTime)281     public void waitNSeconds(int waitTime) {
282         SystemClock.sleep(waitTime);
283     }
284 
285     /**
286      * Executes a shell command on device, and return the standard output in string.
287      *
288      * @param command the command to run
289      * @return the standard output of the command, or empty string if failed without throwing an
290      *     IOException
291      */
executeShellCommand(String command)292     public String executeShellCommand(String command) {
293         validateText(command, /* type= */ "Command");
294         String populatedCommand = populateShellCommand(command);
295         Log.d(
296                 LOG_TAG,
297                 String.format(
298                         "Initial command: %s. Populated command: %s",
299                         command, populatedCommand));
300         try {
301             return mDevice.executeShellCommand(populatedCommand);
302         } catch (IOException e) {
303             // ignore
304             Log.e(
305                     LOG_TAG,
306                     String.format(
307                             "The shell command failed to run: %s, Error: %s",
308                             populatedCommand, e.getMessage()));
309             return "";
310         }
311     }
312 
populateShellCommand(String command)313     private String populateShellCommand(String command) {
314         String populatedCommand = command;
315 
316         // Map of supported substitutions
317         Map<String, String> vars = new HashMap<>();
318         vars.put("user_id", String.valueOf(UserHandle.CURRENT.myUserId()));
319 
320         try (InputStreamReader reader =
321                 new InputStreamReader(
322                         new ByteArrayInputStream(command.getBytes(StandardCharsets.UTF_8)))) {
323             Template template = Template.parseFrom(reader);
324             populatedCommand = template.evaluate(vars);
325             Log.d(
326                     LOG_TAG,
327                     String.format(
328                             "Initial command: %s. Populated command: %s",
329                             command, populatedCommand));
330         } catch (IOException e) {
331             Log.e(
332                     LOG_TAG,
333                     String.format(
334                             "Error populating the shell command template %s, Error: %s",
335                             command, e.getMessage()));
336         }
337         return populatedCommand;
338     }
339 
340     /** Find and return the UI Object that matches the given selector */
findUiObject(BySelector selector)341     public UiObject2 findUiObject(BySelector selector) {
342         validateSelector(selector, /* action= */ "Find UI Object");
343         UiObject2 uiObject = mDevice.wait(Until.findObject(selector), LONG_UI_RESPONSE_WAIT_MS);
344         return uiObject;
345     }
346 
347     /** Find and return the UI Objects that matches the given selector */
findUiObjects(BySelector selector)348     public List<UiObject2> findUiObjects(BySelector selector) {
349         validateSelector(selector, /* action= */ "Find UI Object");
350         List<UiObject2> uiObjects =
351                 mDevice.wait(Until.findObjects(selector), LONG_UI_RESPONSE_WAIT_MS);
352         return uiObjects;
353     }
354 
355     /**
356      * Find the UI Object that matches the given text string.
357      *
358      * @param text Text to search on device UI. It should exactly match the text visible on UI.
359      */
findUiObject(String text)360     public UiObject2 findUiObject(String text) {
361         validateText(text, /* type= */ "Text");
362         return findUiObject(By.text(text));
363     }
364 
365     /**
366      * Find the UI Object in given element.
367      *
368      * @param uiObject Find the ui object(selector) in this element.
369      * @param selector Find this ui object in the given element.
370      */
findUiObjectInGivenElement(UiObject2 uiObject, BySelector selector)371     public UiObject2 findUiObjectInGivenElement(UiObject2 uiObject, BySelector selector) {
372         validateUiObjectAndThrowIllegalArgumentException(
373                 uiObject, /* action= */ "Find UI object in given element");
374         validateSelector(selector, /* action= */ "Find UI object in given element");
375         return uiObject.findObject(selector);
376     }
377 
378     /**
379      * Checks if given text is available on the Device UI. The text should be exactly same as seen
380      * on the screen.
381      *
382      * <p>Given text will be searched on current screen. This method will not scroll on the screen
383      * to check for given text.
384      *
385      * @param text Text to search on device UI
386      * @return Returns True if the text is found, else return False.
387      */
hasUiElement(String text)388     public boolean hasUiElement(String text) {
389         validateText(text, /* type= */ "Text");
390         return hasUiElement(By.text(text));
391     }
392 
393     /**
394      * Scroll using forward and backward buttons on device screen and check if the given text is
395      * present.
396      *
397      * <p>Method throws {@link MissingUiElementException} if the given button selectors are not
398      * available on the Device UI.
399      *
400      * @param forward {@link BySelector} for the button to use for scrolling forward/down.
401      * @param backward {@link BySelector} for the button to use for scrolling backward/up.
402      * @param text Text to search on device UI
403      * @return Returns True if the text is found, else return False.
404      */
scrollAndCheckIfUiElementExist( BySelector forward, BySelector backward, String text)405     public boolean scrollAndCheckIfUiElementExist(
406             BySelector forward, BySelector backward, String text) throws MissingUiElementException {
407         return scrollAndFindUiObject(forward, backward, text) != null;
408     }
409 
410     /**
411      * Scroll by performing forward and backward gestures on device screen and check if the given
412      * text is present on Device UI.
413      *
414      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
415      * scrollAndCheckIfUiElementExist(BySelector scrollableSelector, String text, boolean
416      * isVertical)} by passing isVertical = false.
417      *
418      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
419      * available on the Device UI.
420      *
421      * @param scrollableSelector {@link BySelector} used for scrolling on device UI
422      * @param text Text to search on device UI
423      * @return Returns True if the text is found, else return False.
424      */
scrollAndCheckIfUiElementExist(BySelector scrollableSelector, String text)425     public boolean scrollAndCheckIfUiElementExist(BySelector scrollableSelector, String text)
426             throws MissingUiElementException {
427         return scrollAndCheckIfUiElementExist(scrollableSelector, text, /* isVertical= */ true);
428     }
429 
430     /**
431      * Scroll by performing forward and backward gestures on device screen and check if the given
432      * text is present on Device UI.
433      *
434      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
435      * available on the Device UI.
436      *
437      * @param scrollableSelector {@link BySelector} used for scrolling on device UI
438      * @param text Text to search on device UI
439      * @param isVertical For vertical scrolling, use isVertical = true and For Horizontal scrolling,
440      *     use isVertical = false.
441      * @return Returns True if the text is found, else return False.
442      */
scrollAndCheckIfUiElementExist( BySelector scrollableSelector, String text, boolean isVertical)443     public boolean scrollAndCheckIfUiElementExist(
444             BySelector scrollableSelector, String text, boolean isVertical)
445             throws MissingUiElementException {
446         return scrollAndFindUiObject(scrollableSelector, text, isVertical) != null;
447     }
448 
449     /**
450      * Checks if given target is available on the Device UI.
451      *
452      * <p>Given target will be searched on current screen. This method will not scroll on the screen
453      * to check for given target.
454      *
455      * @param target {@link BySelector} to search on device UI
456      * @return Returns True if the target is found, else return False.
457      */
hasUiElement(BySelector target)458     public boolean hasUiElement(BySelector target) {
459         validateSelector(target, /* action= */ "Check For UI Object");
460         return mDevice.hasObject(target);
461     }
462 
463     /**
464      * Scroll using forward and backward buttons on device screen and check if the given target is
465      * present.
466      *
467      * <p>Method throws {@link MissingUiElementException} if the given button selectors are not
468      * available on the Device UI.
469      *
470      * @param forward {@link BySelector} for the button to use for scrolling forward/down.
471      * @param backward {@link BySelector} for the button to use for scrolling backward/up.
472      * @param target {@link BySelector} to search on device UI
473      * @return Returns True if the target is found, else return False.
474      */
scrollAndCheckIfUiElementExist( BySelector forward, BySelector backward, BySelector target)475     public boolean scrollAndCheckIfUiElementExist(
476             BySelector forward, BySelector backward, BySelector target)
477             throws MissingUiElementException {
478         return scrollAndFindUiObject(forward, backward, target) != null;
479     }
480 
481     /**
482      * Scroll by performing forward and backward gestures on device screen and check if the target
483      * UI Element is present.
484      *
485      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
486      * scrollAndCheckIfUiElementExist(BySelector scrollableSelector, BySelector target, boolean
487      * isVertical)} by passing isVertical = false.
488      *
489      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
490      * available on the Device UI.
491      *
492      * @param scrollableSelector {@link BySelector} used for scrolling on device UI
493      * @param target {@link BySelector} to search on device UI
494      * @return Returns True if the target is found, else return False.
495      */
scrollAndCheckIfUiElementExist(BySelector scrollableSelector, BySelector target)496     public boolean scrollAndCheckIfUiElementExist(BySelector scrollableSelector, BySelector target)
497             throws MissingUiElementException {
498         return scrollAndCheckIfUiElementExist(scrollableSelector, target, /* isVertical= */ true);
499     }
500 
501     /**
502      * Scroll by performing forward and backward gestures on device screen and check if the target
503      * UI Element is present.
504      *
505      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
506      * available on the Device UI.
507      *
508      * @param scrollableSelector {@link BySelector} used for scrolling on device UI
509      * @param target {@link BySelector} to search on device UI
510      * @param isVertical For vertical scrolling, use isVertical = true and For Horizontal scrolling,
511      *     use isVertical = false.
512      * @return Returns True if the target is found, else return False.
513      */
scrollAndCheckIfUiElementExist( BySelector scrollableSelector, BySelector target, boolean isVertical)514     public boolean scrollAndCheckIfUiElementExist(
515             BySelector scrollableSelector, BySelector target, boolean isVertical)
516             throws MissingUiElementException {
517         return scrollAndFindUiObject(scrollableSelector, target, isVertical) != null;
518     }
519 
hasPackageInForeground(String packageName)520     public boolean hasPackageInForeground(String packageName) {
521         validateText(packageName, /* type= */ "Package");
522         return mDevice.hasObject(By.pkg(packageName).depth(0));
523     }
524 
525     /** Click at the specified location on the device */
click(int x, int y)526     public void click(int x, int y) throws IOException {
527         mDevice.click(x, y);
528     }
529 
swipeUp()530     public void swipeUp() {
531         // Swipe Up From bottom of screen to the top in one step
532         swipe(SwipeDirection.BOTTOM_TO_TOP, /*numOfSteps*/ MAX_SWIPE_STEPS);
533     }
534 
swipeDown()535     public void swipeDown() {
536         // Swipe Down From top of screen to the bottom in one step
537         swipe(SwipeDirection.TOP_TO_BOTTOM, /*numOfSteps*/ MAX_SWIPE_STEPS);
538     }
539 
swipeRight()540     public void swipeRight() {
541         // Swipe Right From left of screen to the right in one step
542         swipe(SwipeDirection.LEFT_TO_RIGHT, /*numOfSteps*/ MAX_SWIPE_STEPS);
543     }
544 
swipeLeft()545     public void swipeLeft() {
546         // Swipe Left From right of screen to the left in one step
547         swipe(SwipeDirection.RIGHT_TO_LEFT, /*numOfSteps*/ MAX_SWIPE_STEPS);
548     }
549 
swipe(SwipeDirection swipeDirection, int numOfSteps)550     public void swipe(SwipeDirection swipeDirection, int numOfSteps) {
551         swipe(swipeDirection, numOfSteps, SwipeFraction.DEFAULT);
552     }
553 
554     /**
555      * Perform a swipe gesture
556      *
557      * @param swipeDirection The direction to perform the swipe in
558      * @param numOfSteps How many steps the swipe will take
559      * @param swipeFraction The fraction of the screen to swipe across
560      */
swipe(SwipeDirection swipeDirection, int numOfSteps, SwipeFraction swipeFraction)561     public void swipe(SwipeDirection swipeDirection, int numOfSteps, SwipeFraction swipeFraction) {
562         Rect bounds = getScreenBounds();
563 
564         List<Point> swipePoints = getPointsToSwipe(bounds, swipeDirection, swipeFraction);
565 
566         Point startPoint = swipePoints.get(0);
567         Point finishPoint = swipePoints.get(1);
568 
569         // Swipe from start pont to finish point in given number of steps
570         mDevice.swipe(startPoint.x, startPoint.y, finishPoint.x, finishPoint.y, numOfSteps);
571     }
572 
573     /**
574      * Perform a swipe gesture
575      *
576      * @param swipeDirection The direction to perform the swipe in
577      * @param swipeSpeed How fast to swipe
578      */
swipe(SwipeDirection swipeDirection, SwipeSpeed swipeSpeed)579     public void swipe(SwipeDirection swipeDirection, SwipeSpeed swipeSpeed) throws IOException {
580         swipe(swipeDirection, swipeSpeed.mNumSteps);
581     }
582 
583     /**
584      * Perform a swipe gesture
585      *
586      * @param swipeDirection The direction to perform the swipe in
587      * @param swipeSpeed How fast to swipe
588      * @param swipeFraction The fraction of the screen to swipe across
589      */
swipe( SwipeDirection swipeDirection, SwipeSpeed swipeSpeed, SwipeFraction swipeFraction)590     public void swipe(
591             SwipeDirection swipeDirection, SwipeSpeed swipeSpeed, SwipeFraction swipeFraction)
592             throws IOException {
593         swipe(swipeDirection, swipeSpeed.mNumSteps, swipeFraction);
594     }
595 
getPointsToSwipe( Rect bounds, SwipeDirection swipeDirection, SwipeFraction swipeFraction)596     private List<Point> getPointsToSwipe(
597             Rect bounds, SwipeDirection swipeDirection, SwipeFraction swipeFraction) {
598         int xStart;
599         int yStart;
600         int xFinish;
601         int yFinish;
602 
603         int padXStart = 5;
604         int padXFinish = 5;
605         int padYStart = 5;
606         int padYFinish = 5;
607 
608         switch (swipeFraction) {
609             case FULL:
610                 padXStart = 0;
611                 padXFinish = 0;
612                 padYStart = 0;
613                 padYFinish = 0;
614                 break;
615             case QUARTER:
616                 padXStart = bounds.right / 4;
617                 padYStart = bounds.bottom / 4;
618                 break;
619             case HALF:
620                 padXStart = bounds.centerX();
621                 padYStart = bounds.centerY();
622                 break;
623             case THREEQUARTER:
624                 padXStart = bounds.right / 4 * 3;
625                 padYStart = bounds.bottom / 4 * 3;
626                 break;
627             case DEFAULT:
628                 break; // handled above the switch.
629         }
630 
631         switch (swipeDirection) {
632                 // Scroll left = swipe from left to right.
633             case LEFT_TO_RIGHT:
634                 xStart = bounds.left + padXStart;
635                 xFinish = bounds.right - padXFinish;
636                 yStart = bounds.centerY();
637                 yFinish = bounds.centerY();
638                 break;
639                 // Scroll right = swipe from right to left.
640             case RIGHT_TO_LEFT:
641                 xStart = bounds.right - padXStart;
642                 xFinish = bounds.left + padXFinish;
643                 yStart = bounds.centerY();
644                 yFinish = bounds.centerY();
645                 break;
646                 // Scroll up = swipe from top to bottom.
647             case TOP_TO_BOTTOM:
648                 xStart = bounds.centerX();
649                 xFinish = bounds.centerX();
650                 yStart = bounds.top + padYStart;
651                 yFinish = bounds.bottom - padYFinish;
652                 break;
653                 // Scroll down = swipe to bottom to top.
654             case BOTTOM_TO_TOP:
655             default:
656                 xStart = bounds.centerX();
657                 xFinish = bounds.centerX();
658                 yStart = bounds.bottom - padYStart;
659                 yFinish = bounds.top + padYFinish;
660                 break;
661         }
662 
663         List<Point> swipePoints = new ArrayList<>();
664         // Start Point
665         swipePoints.add(new Point(xStart, yStart));
666         // Finish Point
667         swipePoints.add(new Point(xFinish, yFinish));
668 
669         return swipePoints;
670     }
671 
672     /** Returns a Rect representing the bounds of the screen */
getScreenBounds()673     public Rect getScreenBounds() {
674         return new Rect(
675             /* left= */ 0,
676             /* top= */ 0,
677             /* right= */ mDevice.getDisplayWidth(),
678             /* bottom= */ mDevice.getDisplayHeight());
679     }
680 
swipeRight(UiObject2 uiObject)681     public void swipeRight(UiObject2 uiObject) {
682         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Swipe Right");
683         uiObject.swipe(Direction.RIGHT, SWIPE_PERCENT);
684     }
685 
swipeLeft(UiObject2 uiObject)686     public void swipeLeft(UiObject2 uiObject) {
687         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Swipe Left");
688         uiObject.swipe(Direction.LEFT, SWIPE_PERCENT);
689     }
690 
swipeUp(UiObject2 uiObject)691     public void swipeUp(UiObject2 uiObject) {
692         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Swipe Up");
693         uiObject.swipe(Direction.UP, SWIPE_PERCENT);
694     }
695 
swipeDown(UiObject2 uiObject)696     public void swipeDown(UiObject2 uiObject) {
697         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Swipe Down");
698         uiObject.swipe(Direction.DOWN, SWIPE_PERCENT);
699     }
700 
setTextForUiElement(UiObject2 uiObject, String text)701     public void setTextForUiElement(UiObject2 uiObject, String text) {
702         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Set Text");
703         validateText(text, /* type= */ "Text");
704         uiObject.setText(text);
705     }
706 
getTextForUiElement(UiObject2 uiObject)707     public String getTextForUiElement(UiObject2 uiObject) {
708         validateUiObjectAndThrowIllegalArgumentException(uiObject, /* action= */ "Get Text");
709         return uiObject.getText();
710     }
711 
712     /**
713      * Scroll on the device screen using forward or backward buttons.
714      *
715      * <p>Pass Forward/Down Button Selector to scroll forward. Pass Backward/Up Button Selector to
716      * scroll backward. Method throws {@link MissingUiElementException} if the given button is not
717      * available on the Device UI.
718      *
719      * @param scrollButtonSelector {@link BySelector} for the button to use for scrolling.
720      * @return Method returns true for successful scroll else returns false
721      */
scrollUsingButton(BySelector scrollButtonSelector)722     public boolean scrollUsingButton(BySelector scrollButtonSelector)
723             throws MissingUiElementException {
724         validateSelector(scrollButtonSelector, /* action= */ "Scroll Using Button");
725         UiObject2 scrollButton = findUiObject(scrollButtonSelector);
726         validateUiObjectAndThrowMissingUiElementException(
727                 scrollButton, scrollButtonSelector, /* action= */ "Scroll Using Button");
728 
729         String previousView = getViewHierarchy();
730         if (!scrollButton.isEnabled()) {
731             // Already towards the end, cannot scroll
732             return false;
733         }
734 
735         clickAndWait(scrollButton);
736 
737         String currentView = getViewHierarchy();
738 
739         // If current view is same as previous view, scroll did not work, so return false
740         return !currentView.equals(previousView);
741     }
742 
743     /**
744      * Scroll using forward and backward buttons on device screen and find the text.
745      *
746      * <p>Method throws {@link MissingUiElementException} if the given button selectors are not
747      * available on the Device UI.
748      *
749      * @param forward {@link BySelector} for the button to use for scrolling forward/down.
750      * @param backward {@link BySelector} for the button to use for scrolling backward/up.
751      * @param text Text to search on device UI. It should be exactly same as visible on device UI.
752      * @return {@link UiObject2} for given text will be returned. It returns NULL if given text is
753      *     not found on the Device UI.
754      */
scrollAndFindUiObject(BySelector forward, BySelector backward, String text)755     public UiObject2 scrollAndFindUiObject(BySelector forward, BySelector backward, String text)
756             throws MissingUiElementException {
757         validateText(text, /* type= */ "Text");
758         return scrollAndFindUiObject(forward, backward, By.text(text));
759     }
760 
761     /**
762      * Scroll using forward and backward buttons on device screen and find the target UI Element.
763      *
764      * <p>Method throws {@link MissingUiElementException} if the given button selectors are not
765      * available on the Device UI.
766      *
767      * @param forward {@link BySelector} for the button to use for scrolling forward/down.
768      * @param backward {@link BySelector} for the button to use for scrolling backward/up.
769      * @param target {@link BySelector} for UI Element to search on device UI.
770      * @return {@link UiObject2} for target UI Element will be returned. It returns NULL if given
771      *     target is not found on the Device UI.
772      */
scrollAndFindUiObject( BySelector forward, BySelector backward, BySelector target)773     public UiObject2 scrollAndFindUiObject(
774             BySelector forward, BySelector backward, BySelector target)
775             throws MissingUiElementException {
776         validateSelector(forward, /* action= */ "Scroll Forward");
777         validateSelector(backward, /* action= */ "Scroll Backward");
778         validateSelector(target, /* action= */ "Find UI Object");
779         // Find the object on current page
780         UiObject2 uiObject = findUiObject(target);
781         if (isValidUiObject(uiObject)) {
782             return uiObject;
783         }
784         scrollToBeginning(backward);
785         return scrollForwardAndFindUiObject(forward, target);
786     }
787 
scrollForwardAndFindUiObject(BySelector forward, BySelector target)788     private UiObject2 scrollForwardAndFindUiObject(BySelector forward, BySelector target)
789             throws MissingUiElementException {
790         UiObject2 uiObject = findUiObject(target);
791         if (isValidUiObject(uiObject)) {
792             return uiObject;
793         }
794         int scrollCount = 0;
795         boolean canScroll = true;
796         while (!isValidUiObject(uiObject) && canScroll && scrollCount < MAX_SCROLL_COUNT) {
797             canScroll = scrollUsingButton(forward);
798             scrollCount++;
799             uiObject = findUiObject(target);
800         }
801         return uiObject;
802     }
803 
scrollToBeginning(BySelector backward)804     public void scrollToBeginning(BySelector backward) throws MissingUiElementException {
805         int scrollCount = 0;
806         boolean canScroll = true;
807         while (canScroll && scrollCount < MAX_SCROLL_COUNT) {
808             canScroll = scrollUsingButton(backward);
809             scrollCount++;
810         }
811     }
812 
813     /**
814      * Swipe in a direction until a target UI Object is found
815      *
816      * @param swipeDirection Direction to swipe
817      * @param numOfSteps Ticks per swipe
818      * @param swipeFraction How far to swipe
819      * @param target The UI Object to find
820      * @return The found object, or null if there isn't one
821      */
swipeAndFindUiObject( SwipeDirection swipeDirection, int numOfSteps, SwipeFraction swipeFraction, BySelector target)822     public UiObject2 swipeAndFindUiObject(
823             SwipeDirection swipeDirection,
824             int numOfSteps,
825             SwipeFraction swipeFraction,
826             BySelector target) {
827         validateSelector(target, "Find UI Object");
828         UiObject2 uiObject = findUiObject(target);
829         if (isValidUiObject(uiObject)) {
830             return uiObject;
831         }
832 
833         String previousView = null;
834         String currentView = getViewHierarchy();
835         while (!currentView.equals(previousView)) {
836             swipe(swipeDirection, numOfSteps, swipeFraction);
837             uiObject = findUiObject(target);
838             if (isValidUiObject(uiObject)) {
839                 return uiObject;
840             }
841             previousView = currentView;
842             currentView = getViewHierarchy();
843         }
844         return null;
845     }
846 
847     /** Returns the view hierarchy as XML, as output by `adb shell uiautomator dump`. */
getViewHierarchy()848     public String getViewHierarchy() {
849         try {
850             ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
851             mDevice.dumpWindowHierarchy(outputStream);
852             outputStream.close();
853             return outputStream.toString();
854         } catch (IOException ex) {
855             throw new IllegalStateException("Unable to get view hierarchy.", ex);
856         }
857     }
858 
859     /**
860      * Scroll by performing forward and backward gestures on device screen and find the text.
861      *
862      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
863      * scrollAndFindUiObject(BySelector scrollableSelector, String text, boolean isVertical)} by
864      * passing isVertical = false.
865      *
866      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
867      * available on the Device UI.
868      *
869      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
870      * @param text Text to search on device UI. It should be exactly same as visible on device UI.
871      * @return {@link UiObject2} for given text will be returned. It returns NULL if given text is
872      *     not found on the Device UI.
873      */
scrollAndFindUiObject(BySelector scrollableSelector, String text)874     public UiObject2 scrollAndFindUiObject(BySelector scrollableSelector, String text)
875             throws MissingUiElementException {
876         validateText(text, /* type= */ "Text");
877         return scrollAndFindUiObject(scrollableSelector, By.text(text));
878     }
879 
880     /**
881      * Scroll by performing forward and backward gestures on device screen and find the text.
882      *
883      * <p>For vertical scrolling, use isVertical = true For Horizontal scrolling, use isVertical =
884      * false.
885      *
886      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
887      * available on the Device UI.
888      *
889      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
890      * @param text Text to search on device UI. It should be exactly same as visible on device UI.
891      * @return {@link UiObject2} for given text will be returned. It returns NULL if given text is
892      *     not found on the Device UI.
893      */
scrollAndFindUiObject( BySelector scrollableSelector, String text, boolean isVertical)894     public UiObject2 scrollAndFindUiObject(
895             BySelector scrollableSelector, String text, boolean isVertical)
896             throws MissingUiElementException {
897         validateText(text, /* type= */ "Text");
898         return scrollAndFindUiObject(scrollableSelector, By.text(text), isVertical);
899     }
900 
901     /**
902      * Scroll by performing forward and backward gestures on device screen and find the target UI
903      * Element.
904      *
905      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
906      * scrollAndFindUiObject(BySelector scrollableSelector, BySelector target, boolean isVertical)}
907      * by passing isVertical = false.
908      *
909      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
910      * available on the Device UI.
911      *
912      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
913      * @param target {@link BySelector} for UI Element to search on device UI.
914      * @return {@link UiObject2} for target UI Element will be returned. It returns NULL if given
915      *     target is not found on the Device UI.
916      */
scrollAndFindUiObject(BySelector scrollableSelector, BySelector target)917     public UiObject2 scrollAndFindUiObject(BySelector scrollableSelector, BySelector target)
918             throws MissingUiElementException {
919         return scrollAndFindUiObject(scrollableSelector, target, /* isVertical= */ true);
920     }
921 
922     /**
923      * Scroll by performing forward and backward gestures on device screen and find the target UI
924      * Element.
925      *
926      * <p>For vertical scrolling, use isVertical = true For Horizontal scrolling, use isVertical =
927      * false.
928      *
929      * <p>Method throws {@link MissingUiElementException} if the given scrollable selector is not
930      * available on the Device UI.
931      *
932      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
933      * @param target {@link BySelector} for UI Element to search on device UI.
934      * @param isVertical For vertical scrolling, use isVertical = true and For Horizontal scrolling,
935      *     use isVertical = false.
936      * @return {@link UiObject2} for target UI Element will be returned. It returns NULL if given
937      *     target is not found on the Device UI.
938      */
scrollAndFindUiObject( BySelector scrollableSelector, BySelector target, boolean isVertical)939     public UiObject2 scrollAndFindUiObject(
940             BySelector scrollableSelector, BySelector target, boolean isVertical)
941             throws MissingUiElementException {
942         validateSelector(scrollableSelector, /* action= */ "Scroll");
943         validateSelector(target, /* action= */ "Find UI Object");
944         // Find UI element on current page
945         UiObject2 uiObject = findUiObject(target);
946         if (isValidUiObject(uiObject)) {
947             return uiObject;
948         }
949         scrollToBeginning(scrollableSelector, isVertical);
950         return scrollForwardAndFindUiObject(scrollableSelector, target, isVertical);
951     }
952 
scrollForwardAndFindUiObject( BySelector scrollableSelector, BySelector target, boolean isVertical)953     private UiObject2 scrollForwardAndFindUiObject(
954             BySelector scrollableSelector, BySelector target, boolean isVertical)
955             throws MissingUiElementException {
956         UiObject2 uiObject = findUiObject(target);
957         if (isValidUiObject(uiObject)) {
958             return uiObject;
959         }
960         int scrollCount = 0;
961         boolean canScroll = true;
962         while (!isValidUiObject(uiObject) && canScroll && scrollCount < MAX_SCROLL_COUNT) {
963             canScroll = scrollForward(scrollableSelector, isVertical);
964             scrollCount++;
965             uiObject = findUiObject(target);
966         }
967         return uiObject;
968     }
969 
scrollToBeginning(BySelector scrollableSelector, boolean isVertical)970     public void scrollToBeginning(BySelector scrollableSelector, boolean isVertical)
971             throws MissingUiElementException {
972         int scrollCount = 0;
973         boolean canScroll = true;
974         while (canScroll && scrollCount < MAX_SCROLL_COUNT) {
975             canScroll = scrollBackward(scrollableSelector, isVertical);
976             scrollCount++;
977         }
978     }
979 
getDirection(boolean isVertical, boolean scrollForward)980     private Direction getDirection(boolean isVertical, boolean scrollForward) {
981         // Default Scroll = Vertical and Forward
982         // Go DOWN to scroll forward vertically
983         Direction direction = Direction.DOWN;
984         if (isVertical && !scrollForward) {
985             // Scroll = Vertical and Backward
986             // Go UP to scroll backward vertically
987             direction = Direction.UP;
988         }
989         if (!isVertical && scrollForward) {
990             // Scroll = Horizontal and Forward
991             // Go RIGHT to scroll forward horizontally
992             direction = Direction.RIGHT;
993         }
994         if (!isVertical && !scrollForward) {
995             // Scroll = Horizontal and Backward
996             // Go LEFT to scroll backward horizontally
997             direction = Direction.LEFT;
998         }
999         return direction;
1000     }
1001 
validateAndGetScrollableObject(BySelector scrollableSelector)1002     private UiObject2 validateAndGetScrollableObject(BySelector scrollableSelector)
1003             throws MissingUiElementException {
1004         List<UiObject2> scrollableObjects = findUiObjects(scrollableSelector);
1005         for (UiObject2 scrollableObject : scrollableObjects) {
1006             validateUiObjectAndThrowMissingUiElementException(
1007                     scrollableObject, scrollableSelector, /* action= */ "Scroll");
1008             if (!scrollableObject.isScrollable()) {
1009                 scrollableObject = scrollableObject.findObject(By.scrollable(true));
1010             }
1011             if (scrollableObject != null && scrollableObject.isScrollable()) {
1012                 // if there are multiple, return the first UiObject that is scrollable
1013                 return scrollableObject;
1014             }
1015         }
1016         throw new IllegalStateException(
1017                 String.format(
1018                         "Cannot scroll; Could not find UI Object for selector %s that is scrollable"
1019                                 + " or have scrollable children.",
1020                         scrollableSelector));
1021     }
1022 
1023     /**
1024      * Scroll forward one page by performing forward gestures on device screen.
1025      *
1026      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
1027      * scrollForward(BySelector scrollableSelector, boolean isVertical)} by passing isVertical =
1028      * false.
1029      *
1030      * <p>Method throws {@link MissingUiElementException} if given scrollable selector is not
1031      * available on the Device UI.
1032      *
1033      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
1034      * @return Returns true for successful forward scroll, else false.
1035      */
scrollForward(BySelector scrollableSelector)1036     public boolean scrollForward(BySelector scrollableSelector) throws MissingUiElementException {
1037         return scrollForward(scrollableSelector, /* isVertical= */ true);
1038     }
1039 
1040     /**
1041      * Scroll forward one page by performing forward gestures on device screen.
1042      *
1043      * <p>For vertical scrolling, use isVertical = true For Horizontal scrolling, use isVertical =
1044      * false.
1045      *
1046      * <p>Method throws {@link MissingUiElementException} if given scrollable selector is not
1047      * available on the Device UI.
1048      *
1049      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
1050      * @return Returns true for successful forward scroll, else false.
1051      */
scrollForward(BySelector scrollableSelector, boolean isVertical)1052     public boolean scrollForward(BySelector scrollableSelector, boolean isVertical)
1053             throws MissingUiElementException {
1054         return scroll(scrollableSelector, getDirection(isVertical, /* scrollForward= */ true));
1055     }
1056 
1057     /**
1058      * Scroll backward one page by performing backward gestures on device screen.
1059      *
1060      * <p>Scrolling will be performed vertically by default. For horizontal scrolling use {@code
1061      * scrollBackward(BySelector scrollableSelector, boolean isVertical)} by passing isVertical =
1062      * false.
1063      *
1064      * <p>Method throws {@link MissingUiElementException} if given scrollable selector is not
1065      * available on the Device UI.
1066      *
1067      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
1068      * @return Returns true for successful backard scroll, else false.
1069      */
scrollBackward(BySelector scrollableSelector)1070     public boolean scrollBackward(BySelector scrollableSelector) throws MissingUiElementException {
1071         return scrollBackward(scrollableSelector, /* isVertical= */ true);
1072     }
1073 
1074     /**
1075      * Scroll backward one page by performing backward gestures on device screen.
1076      *
1077      * <p>For vertical scrolling, use isVertical = true For Horizontal scrolling, use isVertical =
1078      * false.
1079      *
1080      * <p>Method throws {@link MissingUiElementException} if given scrollable selector is not
1081      * available on the Device UI.
1082      *
1083      * @param scrollableSelector {@link BySelector} for the scrollable UI Element on device UI.
1084      * @return Returns true for successful backward scroll, else false.
1085      */
scrollBackward(BySelector scrollableSelector, boolean isVertical)1086     public boolean scrollBackward(BySelector scrollableSelector, boolean isVertical)
1087             throws MissingUiElementException {
1088         return scroll(scrollableSelector, getDirection(isVertical, /* scrollForward= */ false));
1089     }
1090 
scroll(BySelector scrollableSelector, Direction direction)1091     private boolean scroll(BySelector scrollableSelector, Direction direction)
1092             throws MissingUiElementException {
1093 
1094         UiObject2 scrollableObject = validateAndGetScrollableObject(scrollableSelector);
1095 
1096         Rect bounds = scrollableObject.getVisibleBounds();
1097         int horizontalMargin = (int) (Math.abs(bounds.width()) / mScrollMargin);
1098         int verticalMargin = (int) (Math.abs(bounds.height()) / mScrollMargin);
1099 
1100         scrollableObject.setGestureMargins(
1101                 horizontalMargin, // left
1102                 verticalMargin, // top
1103                 horizontalMargin, // right
1104                 verticalMargin); // bottom
1105 
1106         String previousView = getViewHierarchy();
1107 
1108         scrollableObject.scroll(direction, SCROLL_PERCENT);
1109         waitNSeconds(mWaitTimeAfterScroll);
1110 
1111         String currentView = getViewHierarchy();
1112 
1113         // If current view is same as previous view, scroll did not work, so return false
1114         return !currentView.equals(previousView);
1115     }
1116 
validateText(String text, String type)1117     private void validateText(String text, String type) {
1118         if (Strings.isNullOrEmpty(text)) {
1119             throw new IllegalArgumentException(
1120                     String.format(
1121                             "Provide a valid %s, current %s value is either NULL or empty.",
1122                             type, type));
1123         }
1124     }
1125 
validateSelector(BySelector selector, String action)1126     private void validateSelector(BySelector selector, String action) {
1127         if (selector == null) {
1128             throw new IllegalArgumentException(
1129                     String.format(
1130                             "Cannot %s; Provide a valid selector to %s, currently it is NULL.",
1131                             action, action));
1132         }
1133     }
1134 
1135     /**
1136      * A simple null-check on a single uiObject2 instance
1137      *
1138      * @param uiObject - The object to be checked.
1139      * @param action - The UI action being performed when the object was generated or searched-for.
1140      */
validateUiObject(UiObject2 uiObject, String action)1141     public void validateUiObject(UiObject2 uiObject, String action) {
1142         if (uiObject == null) {
1143             throw new MissingUiElementException(
1144                     String.format("Unable to find UI Element for %s.", action));
1145         }
1146     }
1147 
1148     /**
1149      * A simple null-check on a list of UIObjects
1150      *
1151      * @param uiObjects - The list to check
1152      * @param action - A string description of the UI action being taken when this list was
1153      *     generated.
1154      */
validateUiObjects(List<UiObject2> uiObjects, String action)1155     public void validateUiObjects(List<UiObject2> uiObjects, String action) {
1156         if (uiObjects == null) {
1157             throw new MissingUiElementException(
1158                     String.format("Unable to find UI Element for %s.", action));
1159         }
1160     }
1161 
isValidUiObject(UiObject2 uiObject)1162     public boolean isValidUiObject(UiObject2 uiObject) {
1163         return uiObject != null;
1164     }
1165 
validateUiObjectAndThrowIllegalArgumentException( UiObject2 uiObject, String action)1166     private void validateUiObjectAndThrowIllegalArgumentException(
1167             UiObject2 uiObject, String action) {
1168         if (!isValidUiObject(uiObject)) {
1169             throw new IllegalArgumentException(
1170                     String.format(
1171                             "Cannot %s; Provide a valid UI Object to %s, currently it is NULL.",
1172                             action, action));
1173         }
1174     }
1175 
validateUiObjectAndThrowMissingUiElementException( UiObject2 uiObject, BySelector selector, String action)1176     private void validateUiObjectAndThrowMissingUiElementException(
1177             UiObject2 uiObject, BySelector selector, String action)
1178             throws MissingUiElementException {
1179         if (!isValidUiObject(uiObject)) {
1180             throw new MissingUiElementException(
1181                     String.format(
1182                             "Cannot %s; Unable to find UI Object for %s selector.",
1183                             action, selector));
1184         }
1185     }
1186 }
1187