// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.test.pagecontroller.utils;
import android.content.Context;
import android.content.Intent;
import android.graphics.Point;
import android.graphics.Rect;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.Process;
import android.os.RemoteException;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.UiDevice;
import androidx.test.uiautomator.UiObject2;
import org.hamcrest.Matchers;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.Callable;
/** Allows tests to perform UI actions. */
public class UiAutomatorUtils {
private static final String TAG = "UiAutomatorUtils";
private static final int SWIPE_STEPS_PER_SECOND = 200;
private static final int MAX_SWIPES = 30;
private static final float DEFAULT_SWIPE_SECONDS_PER_PAGE = 0.2f;
private static final float DEFAULT_SWIPE_SCREEN_FRACTION = 0.6f;
private static final long WAIT_TIMEOUT_MS = 20000L;
private static final long UI_CHECK_INTERVAL = 1000L;
private static final long SHORT_CLICK_DURATION = 10L;
private static final long LONG_CLICK_DURATION = 1000L;
// Give applications more time to launch.
private static final long LAUNCH_TIMEOUT_MS = 9000L;
// 100 steps corresponds to ~1 secs, this was determined
// experimentally. Internally uses UiDevice.drag to simulate
// clicking, steps is one of the parameters to drag.
public static final int CLICK_STEPS_PER_SECOND = 100;
private UiDevice mDevice;
private UiLocatorHelper mLocatorHelper;
private static class LazyHolder {
static final UiAutomatorUtils sInstance = new UiAutomatorUtils();
}
public static UiAutomatorUtils getInstance() {
return LazyHolder.sInstance;
}
private UiAutomatorUtils() {
mDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
mLocatorHelper = new UiLocatorHelper();
}
public String getText(UiObject2 root) {
return root.getText();
}
/**
* Launch application.
* @param packageName Package name of the application.
*/
public void launchApplication(String packageName) {
Log.d(TAG, "Launching " + packageName);
launchApplication(packageName, LAUNCH_TIMEOUT_MS);
}
/**
* Stops the application.
* @param packageName Package name of the application to stop.
*/
public void stopApplication(String packageName) {
Log.d(TAG, "Stopping " + packageName);
try {
executeShellCommand("am force-stop " + packageName);
} catch (IOException e) {
Log.d(TAG, "Failed to stop " + packageName);
e.printStackTrace();
}
}
public void pressBack() {
mDevice.pressBack();
}
public void pressHome() {
mDevice.pressHome();
}
public void pressRecentApps() throws RemoteException {
mDevice.pressRecentApps();
}
/**
* Takes device screenshot and saves it to screenShotFile.
* @param screenShotFile Where the screenshot should be saved.
*/
public void takeScreenShot(@NonNull File screenShotFile) {
if (mDevice.takeScreenshot(screenShotFile)) {
Log.d(TAG, "Screenshot successfully saved to " + screenShotFile.getAbsolutePath());
} else {
Log.e(TAG, "Screenshot unsuccessful " + screenShotFile.getAbsolutePath());
}
}
/**
* Performs click outside of the UI element found using locator.
* The click will be centered in the rectangular screen area with the greatest
* width or height that does not overlap with the UI element.
* @param locator Locator to use to find the element.
*/
public void clickOutsideOf(@NonNull IUi2Locator locator) {
Rect bounds = getBounds(locator);
Log.d(
TAG,
"Clicking outside of bounds with Bottom:"
+ bounds.bottom
+ " Top:"
+ bounds.top
+ " Left:"
+ bounds.left
+ " Right:"
+ bounds.right);
clickOutsideOfArea(bounds.left, bounds.top, bounds.right, bounds.bottom);
}
/** Get the UiLocatorHelper. */
public UiLocatorHelper getLocatorHelper() {
return mLocatorHelper;
}
/**
* Get a copy of the UiLocatorHelper with a different timeout.
* @param timeout The timeout in milliseconds.
* @return UiLocatorHelper with the specified timeout.
*/
public UiLocatorHelper getLocatorHelper(long timeout) {
return new UiLocatorHelper(timeout);
}
/**
* Performs a long click on node.
* @param locator Locator to use to find the node.
*/
public void longClick(@NonNull IUi2Locator locator) {
clickDurationInternal(locator, LONG_CLICK_DURATION);
}
/**
* Perform a click.
* @param locator Locator to find the UI element to click on.
* @param duration Milliseconds that the click should last for.
*/
private void clickDurationInternal(IUi2Locator locator, long duration) {
UiObject2 object2 = mLocatorHelper.getOne(locator);
Point center = object2.getVisibleCenter();
mDevice.swipe(
center.x,
center.y,
center.x,
center.y,
(int) (CLICK_STEPS_PER_SECOND * duration / 1000L));
}
/**
* Get the rectangular bounds of the first UI element found using locator.
* @param locator Locator used to find the UI element.
* @return Bounds of the UI element.
*/
private Rect getBounds(@NonNull IUi2Locator locator) {
UiObject2 object2 = mLocatorHelper.getOne(locator);
return object2.getVisibleBounds();
}
/**
* Copied over from UiAutomator UiDevice v18.0.1, it was removed for some reason, but is useful.
* Executes a shell command using shell user identity, and return the standard output in string.
* <p>
* Calling function with large amount of output will have memory impacts, and the function call
* will block if the command executed is blocking.
* <p>Note: calling this function requires API level 21 or above
* @param cmd Command to run
* @return The standard output of the command
* @throws IOException
* @since API Level 21
*/
public String executeShellCommand(@NonNull String cmd) throws IOException {
ParcelFileDescriptor pfd =
InstrumentationRegistry.getInstrumentation()
.getUiAutomation()
.executeShellCommand(cmd);
byte[] buf = new byte[512];
int bytesRead;
FileInputStream fis = new ParcelFileDescriptor.AutoCloseInputStream(pfd);
StringBuilder stdout = new StringBuilder();
while ((bytesRead = fis.read(buf)) != -1) {
stdout.append(new String(buf, 0, bytesRead));
}
fis.close();
return stdout.toString();
}
/**
* Click on a node.
* @param locator Locator used to find the node.
* @throws UiLocationException If locator didn't find any nodes within timeout interval.
*/
public void click(@NonNull IUi2Locator locator) {
clickDurationInternal(locator, SHORT_CLICK_DURATION);
}
/**
* Click on a node and checks for an expected outcome.
* @param locator Locator used to find the node to click on.
* @param outcomeLocator Locator to check for existence after the click.
* @throws UiLocationException If locator didn't find any nodes within timeout interval or if
* provided outcomeLocator didn't find any nodes after the click.
*/
public void click(@NonNull IUi2Locator locator, @NonNull IUi2Locator outcomeLocator) {
clickInternal(locator, outcomeLocator);
}
/**
* Enters text in a node and press enter.
* @param locator Locator used to find the node.
* @param text The text to enter.
* @throws UiLocationException If locator didn't find any nodes within timeout interval.
*/
public void setTextAndEnter(@NonNull IUi2Locator locator, @NonNull String text) {
click(locator);
UiObject2 root = mLocatorHelper.getOne(locator);
root.setText(text);
mDevice.pressEnter();
}
/**
* Performs the swipe up gesture repeatedly until a locator is found.
* @param locator locator that will stop the swipe if found on screen.
* @param stopLocator locator that will cause an UiLocationException if found before locator.
* @throws UiLocationException
*/
public void swipeUpVerticallyUntilFound(IUi2Locator locator, IUi2Locator stopLocator) {
swipeVerticallyUntilFound(locator, stopLocator, DEFAULT_SWIPE_SCREEN_FRACTION);
}
/**
* Performs the swipe down gesture repeatedly until a locator is found.
* @param locator locator that will stop the swipe if found on screen.
* @param stopLocator locator that will cause an UiLocationException if found before locator.
* @throws UiLocationException
*/
public void swipeDownVerticallyUntilFound(IUi2Locator locator, IUi2Locator stopLocator) {
swipeVerticallyUntilFound(locator, stopLocator, -DEFAULT_SWIPE_SCREEN_FRACTION);
}
/**
* Performs a swipe down gesture that's centered on the screen.
* If the intention is to scroll to an element, consider using swipeDownVerticallyUntilFound.
* @param fractionOfScreen The length of the swipe as a fraction of the screen, with 1 being
* the max.
*/
public void swipeDownVertically(float fractionOfScreen) {
if (fractionOfScreen > 1 || fractionOfScreen <= 0) {
throw new IllegalArgumentException("fractionOfScreen must be in the interval [0,1]");
}
swipeVertically(-fractionOfScreen);
}
/**
* Performs a swipe up gesture that's centered on the screen.
* If the intention is to scroll to an element, consider using swipeUpVerticallyUntilFound.
* @param fractionOfScreen The length of the swipe as a fraction of the screen, with 1 being
* the max.
*/
public void swipeUpVertically(float fractionOfScreen) {
if (fractionOfScreen > 1 || fractionOfScreen <= 0) {
throw new IllegalArgumentException("fractionOfScreen must be in the interval [0,1]");
}
swipeVertically(fractionOfScreen);
}
/**
* Performs a swipe gesture between 2 locators.
* @param beginLocator Center the start of swipe on beginLocator.
* @param endLocator Center the end of swipe on endLocator.
* @param duration Time the swipe should take in milliseconds.
*/
public void swipe(IUi2Locator beginLocator, IUi2Locator endLocator, float duration) {
UiObject2 begin = mLocatorHelper.getOne(beginLocator);
UiObject2 end = mLocatorHelper.getOne(endLocator);
Point beginPoint = begin.getVisibleCenter();
Point endPoint = end.getVisibleCenter();
int steps = (int) (duration * SWIPE_STEPS_PER_SECOND / 1000);
steps = steps > 0 ? steps : 1;
mDevice.swipe(beginPoint.x, beginPoint.y, endPoint.x, endPoint.y, steps);
}
/**
* Prints the UiAutomator window hierarchy to logcat.
* @param message A leading message for the debug log.
*/
public void printWindowHierarchy(String message) {
try {
List<String> hierarchy = getWindowHierarchy();
Log.d(TAG, message);
for (String line : hierarchy) {
Log.d(TAG, line);
}
} catch (IOException e) {
// Just log any errors and move on, testing can still continue.
Log.e(TAG, "Printing hierarchy", e);
}
}
public void waitUntilAnyVisible(IUi2Locator... locators) {
CriteriaHelper.pollInstrumentationThread(
toNotSatisfiedRunnable(
() -> {
for (IUi2Locator locator : locators) {
if (mLocatorHelper.isOnScreen(locator)) {
return true;
}
}
return false;
},
"No Chrome views on screen. (i.e. Chrome has crashed "
+ "on startup). Look at earlier logs for the actual "
+ "crash stacktrace."),
WAIT_TIMEOUT_MS,
UI_CHECK_INTERVAL);
}
private static Runnable toNotSatisfiedRunnable(
Callable<Boolean> criteria, String failureReason) {
return () -> {
try {
boolean isSatisfied = criteria.call();
Criteria.checkThat(failureReason, isSatisfied, Matchers.is(true));
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
private void launchApplication(String packageName, long timeout) {
Context context = ApplicationProvider.getApplicationContext();
final Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName);
if (intent == null) {
throw new IllegalStateException(
"Could not get intent to launch "
+ packageName
+ ", please ensure that it is installed");
}
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
context.startActivity(intent);
IUi2Locator packageLocator = Ui2Locators.withPackageName(packageName);
UiLocatorHelper helper = getLocatorHelper(timeout);
helper.getOne(packageLocator);
}
// positive fraction indicates swipe up
/**
* @throws UiLocationException
*/
private void swipeVerticallyUntilFound(
IUi2Locator locator, IUi2Locator stopLocator, float fractionOfScreen) {
if (mLocatorHelper.isOnScreen(locator)) return;
Utils.sleep(UiLocatorHelper.UI_CHECK_INTERVAL_MS);
int iterationsLeft = MAX_SWIPES;
while (!mLocatorHelper.isOnScreen(locator) && iterationsLeft-- > 0) {
if (mLocatorHelper.isOnScreen(stopLocator)) {
throw new UiLocationException(
"Did not find locator while swiping to " + stopLocator + ".", locator);
}
swipeVertically(fractionOfScreen);
Utils.sleep(UiLocatorHelper.UI_CHECK_INTERVAL_MS);
}
if (!mLocatorHelper.isOnScreen(locator)) {
throw new UiLocationException(
"Did not find locator after swiping for " + MAX_SWIPES + " times.", locator);
}
}
private List<String> getWindowHierarchy() throws IOException {
File tempFile = null;
try {
tempFile = getTempFile("temp_window_hierarchy", ".txt");
mDevice.dumpWindowHierarchy(tempFile.getAbsolutePath());
List<String> hierarchy = readAllFromFile(tempFile);
List<String> formattedHiearchy = formatXml(hierarchy, 2);
return formattedHiearchy;
} finally {
if (tempFile != null) {
tempFile.delete();
}
}
}
// TODO(aluo): Refactor this to use standard libraries, see if Apache
// xml-commons can be used for this.
// Formats one-liner xml hierarchy dump into properly indented list of tags
// to ease readability in logs.
private List<String> formatXml(List<String> inputs, int indentSpaces) {
StringBuilder inputBuilder = new StringBuilder();
List<String> output = new ArrayList<>();
for (String line : inputs) {
inputBuilder.append(line.trim());
}
String xmlLine = inputBuilder.toString();
int indent = 0;
StringBuilder lineBuilder = new StringBuilder();
int i;
for (i = 0; i < xmlLine.length() - 1; i++) {
char c = xmlLine.charAt(i);
char nextC = xmlLine.charAt(i + 1);
if (c == '<') {
if (nextC == '/') {
indent--;
}
lineBuilder.append(new String(new char[indent * indentSpaces]).replace("\0", " "));
lineBuilder.append(c);
if (nextC != '/') {
indent++;
}
} else if (c == '>') {
lineBuilder.append(c);
output.add(lineBuilder.toString());
lineBuilder.delete(0, lineBuilder.length());
} else if (c == '/' && nextC == '>') {
indent--;
lineBuilder.append(c);
} else {
lineBuilder.append(c);
}
}
if (xmlLine.length() > 0) {
lineBuilder.append(xmlLine.charAt(i));
output.add(lineBuilder.toString());
}
return output;
}
/**
* Swipe screen vertically by fractions of screen height.
* @param fractionOfScreen Amount to swipe by, -1 to 1.
* Negative value swipes down, positive up.
*/
private void swipeVertically(float fractionOfScreen) {
int x = mDevice.getDisplayWidth() / 2;
int h = mDevice.getDisplayHeight();
int startY = h / 2 - (int) (fractionOfScreen / 2f * h);
int stopY = startY + (int) (fractionOfScreen * h);
int steps =
(int)
(DEFAULT_SWIPE_SECONDS_PER_PAGE
* Math.abs(fractionOfScreen)
* SWIPE_STEPS_PER_SECOND);
Log.d(
TAG,
"Swiping vertically from " + stopY + " to " + startY + " in " + steps + " steps");
mDevice.swipe(x, stopY, x, startY, steps);
}
private void clickOutsideOfArea(int minX, int minY, int maxX, int maxY) {
int screenHeight = mDevice.getDisplayHeight();
int screenWidth = mDevice.getDisplayWidth();
// Calculate horizontal and vertical margins from rect to screen's edge
int leftMargin = minX;
int rightMargin = screenWidth - maxX;
int topMargin = minY;
int bottomMargin = screenHeight - maxY;
// Find the biggest margin value (widest or tallest)
int maxMargin =
Collections.max(Arrays.asList(rightMargin, leftMargin, topMargin, bottomMargin));
// Click on the center of the area with the biggest margin value,
// the order chosen here is arbitrary in case of a tie.
if (maxMargin == rightMargin) {
mDevice.click(maxX + rightMargin / 2, screenHeight / 2);
} else if (maxMargin == leftMargin) {
mDevice.click(leftMargin / 2, screenHeight / 2);
} else if (maxMargin == topMargin) {
mDevice.click(screenWidth / 2, topMargin / 2);
} else if (maxMargin == bottomMargin) {
mDevice.click(screenWidth / 2, screenHeight - bottomMargin / 2);
}
}
private File getTempFile(String prefix, String suffix) throws IOException {
File cacheDir = Environment.getExternalStorageDirectory();
Log.d(TAG, "Create temp file in: " + cacheDir);
Log.d(TAG, "My user id: " + Process.myUid());
return File.createTempFile(prefix, suffix, cacheDir);
}
private void clickInternal(IUi2Locator locator, IUi2Locator outcomeLocator) {
clickDurationInternal(locator, SHORT_CLICK_DURATION);
if (outcomeLocator != null) {
mLocatorHelper.getOne(outcomeLocator);
}
// If outcomeLocator is not specified, then caller intended not to check the
// effect of the click here.
}
private List<String> readAllFromFile(File file) throws IOException {
List<String> strings = new ArrayList<>();
try (FileInputStream fileStream = new FileInputStream(file);
InputStreamReader inputStream = new InputStreamReader(fileStream);
BufferedReader streamReader = new BufferedReader(inputStream)) {
String line;
while ((line = streamReader.readLine()) != null) {
strings.add(line);
}
}
Log.d(
TAG,
"readAllFromFile read " + strings.size() + " lines from " + file.getAbsolutePath());
return strings;
}
}