blob: 1fe998e9664edac9b64055e781d0359b318c6799 [file] [log] [blame]
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.autofillservice.cts;
import static android.autofillservice.cts.Timeouts.DATASET_PICKER_NOT_SHOWN_NAPTIME_MS;
import static android.autofillservice.cts.Timeouts.SAVE_NOT_SHOWN_NAPTIME_MS;
import static android.autofillservice.cts.Timeouts.SAVE_TIMEOUT;
import static android.autofillservice.cts.Timeouts.UI_DATASET_PICKER_TIMEOUT;
import static android.autofillservice.cts.Timeouts.UI_SCREEN_ORIENTATION_TIMEOUT;
import static android.autofillservice.cts.Timeouts.UI_TIMEOUT;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_ADDRESS;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_GENERIC;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_PASSWORD;
import static android.service.autofill.SaveInfo.SAVE_DATA_TYPE_USERNAME;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
import android.app.Instrumentation;
import android.app.UiAutomation;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.os.SystemClock;
import android.service.autofill.SaveInfo;
import android.support.test.uiautomator.By;
import android.support.test.uiautomator.BySelector;
import android.support.test.uiautomator.UiDevice;
import android.support.test.uiautomator.UiObject2;
import android.support.test.uiautomator.Until;
import android.text.Html;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityWindowInfo;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.InstrumentationRegistry;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeoutException;
/**
* Helper for UI-related needs.
*/
final class UiBot {
private static final String TAG = "AutoFillCtsUiBot";
private static final String RESOURCE_ID_DATASET_PICKER = "autofill_dataset_picker";
private static final String RESOURCE_ID_DATASET_HEADER = "autofill_dataset_header";
private static final String RESOURCE_ID_SAVE_SNACKBAR = "autofill_save";
private static final String RESOURCE_ID_SAVE_ICON = "autofill_save_icon";
private static final String RESOURCE_ID_SAVE_TITLE = "autofill_save_title";
private static final String RESOURCE_ID_CONTEXT_MENUITEM = "floating_toolbar_menu_item_text";
private static final String RESOURCE_ID_SAVE_BUTTON_NO = "autofill_save_no";
private static final String RESOURCE_STRING_SAVE_TITLE = "autofill_save_title";
private static final String RESOURCE_STRING_SAVE_TITLE_WITH_TYPE =
"autofill_save_title_with_type";
private static final String RESOURCE_STRING_SAVE_TYPE_PASSWORD = "autofill_save_type_password";
private static final String RESOURCE_STRING_SAVE_TYPE_ADDRESS = "autofill_save_type_address";
private static final String RESOURCE_STRING_SAVE_TYPE_CREDIT_CARD =
"autofill_save_type_credit_card";
private static final String RESOURCE_STRING_SAVE_TYPE_USERNAME = "autofill_save_type_username";
private static final String RESOURCE_STRING_SAVE_TYPE_EMAIL_ADDRESS =
"autofill_save_type_email_address";
private static final String RESOURCE_STRING_SAVE_BUTTON_NOT_NOW = "save_password_notnow";
private static final String RESOURCE_STRING_SAVE_BUTTON_NO_THANKS = "autofill_save_no";
private static final String RESOURCE_STRING_AUTOFILL = "autofill";
private static final String RESOURCE_STRING_DATASET_PICKER_ACCESSIBILITY_TITLE =
"autofill_picker_accessibility_title";
private static final String RESOURCE_STRING_SAVE_SNACKBAR_ACCESSIBILITY_TITLE =
"autofill_save_accessibility_title";
static final BySelector DATASET_PICKER_SELECTOR = By.res("android", RESOURCE_ID_DATASET_PICKER);
private static final BySelector SAVE_UI_SELECTOR = By.res("android", RESOURCE_ID_SAVE_SNACKBAR);
private static final BySelector DATASET_HEADER_SELECTOR =
By.res("android", RESOURCE_ID_DATASET_HEADER);
// TODO: figure out a more reliable solution that does not depend on SystemUI resources.
private static final String SPLIT_WINDOW_DIVIDER_ID =
"com.android.systemui:id/docked_divider_background";
private static final boolean DUMP_ON_ERROR = true;
/** Pass to {@link #setScreenOrientation(int)} to change the display to portrait mode */
public static int PORTRAIT = 0;
/** Pass to {@link #setScreenOrientation(int)} to change the display to landscape mode */
public static int LANDSCAPE = 1;
private final UiDevice mDevice;
private final Context mContext;
private final String mPackageName;
private final UiAutomation mAutoman;
private final Timeout mDefaultTimeout;
private boolean mOkToCallAssertNoDatasets;
UiBot() {
this(UI_TIMEOUT);
}
UiBot(Timeout defaultTimeout) {
mDefaultTimeout = defaultTimeout;
final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
mDevice = UiDevice.getInstance(instrumentation);
mContext = instrumentation.getContext();
mPackageName = mContext.getPackageName();
mAutoman = instrumentation.getUiAutomation();
}
void reset() {
mOkToCallAssertNoDatasets = false;
}
UiDevice getDevice() {
return mDevice;
}
/**
* Asserts the dataset picker is not shown anymore.
*
* @throws IllegalStateException if called *before* an assertion was made to make sure the
* dataset picker is shown - if that's not the case, call
* {@link #assertNoDatasetsEver()} instead.
*/
void assertNoDatasets() throws Exception {
if (!mOkToCallAssertNoDatasets) {
throw new IllegalStateException(
"Cannot call assertNoDatasets() without calling assertDatasets first");
}
mDevice.wait(Until.gone(DATASET_PICKER_SELECTOR), UI_DATASET_PICKER_TIMEOUT.ms());
mOkToCallAssertNoDatasets = false;
}
/**
* Asserts the dataset picker was never shown.
*
* <p>This method is slower than {@link #assertNoDatasets()} and should only be called in the
* cases where the dataset picker was not previous shown.
*/
void assertNoDatasetsEver() throws Exception {
assertNeverShown("dataset picker", DATASET_PICKER_SELECTOR,
DATASET_PICKER_NOT_SHOWN_NAPTIME_MS);
}
/**
* Asserts the dataset chooser is shown and contains exactly the given datasets.
*
* @return the dataset picker object.
*/
UiObject2 assertDatasets(String...names) throws Exception {
// TODO: change run() so it can rethrow the original message
return UI_DATASET_PICKER_TIMEOUT.run("assertDatasets: " + Arrays.toString(names), () -> {
final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
try {
// TODO: use a library to check it contains, instead of asserThat + catch exception
assertWithMessage("wrong dataset names").that(getChildrenAsText(picker))
.containsExactlyElementsIn(Arrays.asList(names)).inOrder();
return picker;
} catch (AssertionError e) {
// Value mismatch - most likely UI didn't change yet, try again
Log.w(TAG, "datasets don't match yet: " + e.getMessage());
return null;
}
});
}
/**
* Asserts the dataset chooser is shown and contains the given datasets.
*
* @return the dataset picker object.
*/
UiObject2 assertDatasetsContains(String...names) throws Exception {
// TODO: change run() so it can rethrow the original message
return UI_DATASET_PICKER_TIMEOUT.run("assertDatasets: " + Arrays.toString(names), () -> {
final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
try {
// TODO: use a library to check it contains, instead of asserThat + catch exception
assertWithMessage("wrong dataset names").that(getChildrenAsText(picker))
.containsAllIn(Arrays.asList(names)).inOrder();
return picker;
} catch (AssertionError e) {
// Value mismatch - most likely UI didn't change yet, try again
Log.w(TAG, "datasets don't match yet: " + e.getMessage());
return null;
}
});
}
/**
* Asserts the dataset chooser is shown and contains the given datasets, header, and footer.
* <p>In fullscreen, header view is not under R.id.autofill_dataset_picker.
*
* @return the dataset picker object.
*/
UiObject2 assertDatasetsWithBorders(String header, String footer, String...names)
throws Exception {
final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
final List<String> expectedChild = new ArrayList<>();
if (header != null) {
if (Helper.isAutofillWindowFullScreen(mContext)) {
final UiObject2 headerView = waitForObject(DATASET_HEADER_SELECTOR,
UI_DATASET_PICKER_TIMEOUT);
assertWithMessage("fullscreen wrong dataset header")
.that(getChildrenAsText(headerView))
.containsExactlyElementsIn(Arrays.asList(header)).inOrder();
} else {
expectedChild.add(header);
}
}
expectedChild.addAll(Arrays.asList(names));
if (footer != null) {
expectedChild.add(footer);
}
assertWithMessage("wrong elements on dataset picker").that(getChildrenAsText(picker))
.containsExactlyElementsIn(expectedChild).inOrder();
return picker;
}
/**
* Gets the text of this object children.
*/
List<String> getChildrenAsText(UiObject2 object) {
final List<String> list = new ArrayList<>();
getChildrenAsText(object, list);
return list;
}
private static void getChildrenAsText(UiObject2 object, List<String> children) {
final String text = object.getText();
if (text != null) {
children.add(text);
}
for (UiObject2 child : object.getChildren()) {
getChildrenAsText(child, children);
}
}
/**
* Selects a dataset that should be visible in the floating UI.
*/
void selectDataset(String name) throws Exception {
final UiObject2 picker = findDatasetPicker(UI_DATASET_PICKER_TIMEOUT);
selectDataset(picker, name);
}
/**
* Selects a dataset that should be visible in the floating UI.
*/
void selectDataset(UiObject2 picker, String name) {
final UiObject2 dataset = picker.findObject(By.text(name));
if (dataset == null) {
throw new AssertionError("no dataset " + name + " in " + getChildrenAsText(picker));
}
dataset.click();
}
/**
* Selects a view by text.
*
* <p><b>NOTE:</b> when selecting an option in dataset picker is shown, prefer
* {@link #selectDataset(String)}.
*/
void selectByText(String name) throws Exception {
Log.v(TAG, "selectByText(): " + name);
final UiObject2 object = waitForObject(By.text(name));
object.click();
}
/**
* Asserts a text is shown.
*
* <p><b>NOTE:</b> when asserting the dataset picker is shown, prefer
* {@link #assertDatasets(String...)}.
*/
public UiObject2 assertShownByText(String text) throws Exception {
return assertShownByText(text, mDefaultTimeout);
}
public UiObject2 assertShownByText(String text, Timeout timeout) throws Exception {
final UiObject2 object = waitForObject(By.text(text), timeout);
assertWithMessage("No node with text '%s'", text).that(object).isNotNull();
return object;
}
/**
* Asserts that the text is not showing for sure in the screen "as is", i.e., without waiting
* for it.
*
* <p>Typically called after another assertion that waits for a condition to be shown.
*/
public void assertNotShowingForSure(String text) throws Exception {
final UiObject2 object = mDevice.findObject(By.text(text));
assertWithMessage("Find node with text '%s'", text).that(object).isNull();
}
/**
* Asserts a node with the given content description is shown.
*
*/
public UiObject2 assertShownByContentDescription(String contentDescription) throws Exception {
final UiObject2 object = waitForObject(By.desc(contentDescription));
assertWithMessage("No node with content description '%s'", contentDescription).that(object)
.isNotNull();
return object;
}
/**
* Checks if a View with a certain text exists.
*/
boolean hasViewWithText(String name) {
Log.v(TAG, "hasViewWithText(): " + name);
return mDevice.findObject(By.text(name)) != null;
}
/**
* Selects a view by id.
*/
UiObject2 selectByRelativeId(String id) throws Exception {
Log.v(TAG, "selectByRelativeId(): " + id);
UiObject2 object = waitForObject(By.res(mPackageName, id));
object.click();
return object;
}
/**
* Asserts the id is shown on the screen.
*/
UiObject2 assertShownById(String id) throws Exception {
final UiObject2 object = waitForObject(By.res(id));
assertThat(object).isNotNull();
return object;
}
/**
* Asserts the id is shown on the screen, using a resource id from the test package.
*/
UiObject2 assertShownByRelativeId(String id) throws Exception {
return assertShownByRelativeId(id, mDefaultTimeout);
}
UiObject2 assertShownByRelativeId(String id, Timeout timeout) throws Exception {
final UiObject2 obj = waitForObject(By.res(mPackageName, id), timeout);
assertThat(obj).isNotNull();
return obj;
}
/**
* Asserts the id is not shown on the screen anymore, using a resource id from the test package.
*
* <p><b>Note:</b> this method should only called AFTER the id was previously shown, otherwise
* it might pass without really asserting anything.
*/
void assertGoneByRelativeId(String id, Timeout timeout) {
boolean gone = mDevice.wait(Until.gone(By.res(mPackageName, id)), timeout.ms());
if (!gone) {
final String message = "Object with id '" + id + "' should be gone after "
+ timeout + " ms";
dumpScreen(message);
throw new RetryableException(message);
}
}
/**
* Asserts that a {@code selector} is not showing after {@code timeout} milliseconds.
*/
private void assertNeverShown(String description, BySelector selector, long timeout)
throws Exception {
SystemClock.sleep(timeout);
final UiObject2 object = mDevice.findObject(selector);
if (object != null) {
throw new AssertionError(
String.format("Should not be showing %s after %dms, but got %s",
description, timeout, getChildrenAsText(object)));
}
}
/**
* Gets the text set on a view.
*/
String getTextByRelativeId(String id) throws Exception {
return waitForObject(By.res(mPackageName, id)).getText();
}
/**
* Focus in the view with the given resource id.
*/
void focusByRelativeId(String id) throws Exception {
waitForObject(By.res(mPackageName, id)).click();
}
/**
* Sets a new text on a view.
*/
void setTextByRelativeId(String id, String newText) throws Exception {
waitForObject(By.res(mPackageName, id)).setText(newText);
}
/**
* Asserts the save snackbar is showing and returns it.
*/
UiObject2 assertSaveShowing(int type) throws Exception {
return assertSaveShowing(SAVE_TIMEOUT, type);
}
/**
* Asserts the save snackbar is showing and returns it.
*/
UiObject2 assertSaveShowing(Timeout timeout, int type) throws Exception {
return assertSaveShowing(null, timeout, type);
}
/**
* Presses the Back button.
*/
void pressBack() {
Log.d(TAG, "pressBack()");
mDevice.pressBack();
}
/**
* Presses the Home button.
*/
void pressHome() {
Log.d(TAG, "pressHome()");
mDevice.pressHome();
}
/**
* Asserts the save snackbar is not showing.
*/
void assertSaveNotShowing(int type) throws Exception {
assertNeverShown("save UI for type " + type, SAVE_UI_SELECTOR, SAVE_NOT_SHOWN_NAPTIME_MS);
}
private String getSaveTypeString(int type) {
final String typeResourceName;
switch (type) {
case SAVE_DATA_TYPE_PASSWORD:
typeResourceName = RESOURCE_STRING_SAVE_TYPE_PASSWORD;
break;
case SAVE_DATA_TYPE_ADDRESS:
typeResourceName = RESOURCE_STRING_SAVE_TYPE_ADDRESS;
break;
case SAVE_DATA_TYPE_CREDIT_CARD:
typeResourceName = RESOURCE_STRING_SAVE_TYPE_CREDIT_CARD;
break;
case SAVE_DATA_TYPE_USERNAME:
typeResourceName = RESOURCE_STRING_SAVE_TYPE_USERNAME;
break;
case SAVE_DATA_TYPE_EMAIL_ADDRESS:
typeResourceName = RESOURCE_STRING_SAVE_TYPE_EMAIL_ADDRESS;
break;
default:
throw new IllegalArgumentException("Unsupported type: " + type);
}
return getString(typeResourceName);
}
UiObject2 assertSaveShowing(String description, int... types) throws Exception {
return assertSaveShowing(SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, description,
SAVE_TIMEOUT, types);
}
UiObject2 assertSaveShowing(String description, Timeout timeout, int... types)
throws Exception {
return assertSaveShowing(SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, description, timeout,
types);
}
UiObject2 assertSaveShowing(int negativeButtonStyle, String description,
int... types) throws Exception {
return assertSaveShowing(negativeButtonStyle, description, SAVE_TIMEOUT, types);
}
UiObject2 assertSaveShowing(int negativeButtonStyle, String description, Timeout timeout,
int... types) throws Exception {
final UiObject2 snackbar = waitForObject(SAVE_UI_SELECTOR, timeout);
final UiObject2 titleView =
waitForObject(snackbar, By.res("android", RESOURCE_ID_SAVE_TITLE), timeout);
assertWithMessage("save title (%s) is not shown", RESOURCE_ID_SAVE_TITLE).that(titleView)
.isNotNull();
final UiObject2 iconView =
waitForObject(snackbar, By.res("android", RESOURCE_ID_SAVE_ICON), timeout);
assertWithMessage("save icon (%s) is not shown", RESOURCE_ID_SAVE_ICON).that(iconView)
.isNotNull();
final String actualTitle = titleView.getText();
Log.d(TAG, "save title: " + actualTitle);
final String serviceLabel = InstrumentedAutoFillService.getServiceLabel();
switch (types.length) {
case 1:
final String expectedTitle = (types[0] == SAVE_DATA_TYPE_GENERIC)
? Html.fromHtml(getString(RESOURCE_STRING_SAVE_TITLE,
serviceLabel), 0).toString()
: Html.fromHtml(getString(RESOURCE_STRING_SAVE_TITLE_WITH_TYPE,
getSaveTypeString(types[0]), serviceLabel), 0).toString();
assertThat(actualTitle).isEqualTo(expectedTitle);
break;
case 2:
// We cannot predict the order...
assertThat(actualTitle).contains(getSaveTypeString(types[0]));
assertThat(actualTitle).contains(getSaveTypeString(types[1]));
break;
case 3:
// We cannot predict the order...
assertThat(actualTitle).contains(getSaveTypeString(types[0]));
assertThat(actualTitle).contains(getSaveTypeString(types[1]));
assertThat(actualTitle).contains(getSaveTypeString(types[2]));
break;
default:
throw new IllegalArgumentException("Invalid types: " + Arrays.toString(types));
}
if (description != null) {
final UiObject2 saveSubTitle = snackbar.findObject(By.text(description));
assertWithMessage("save subtitle(%s)", description).that(saveSubTitle).isNotNull();
}
final String negativeButtonStringId =
(negativeButtonStyle == SaveInfo.NEGATIVE_BUTTON_STYLE_REJECT)
? RESOURCE_STRING_SAVE_BUTTON_NOT_NOW
: RESOURCE_STRING_SAVE_BUTTON_NO_THANKS;
final String expectedNegativeButtonText = getString(negativeButtonStringId).toUpperCase();
final UiObject2 negativeButton = waitForObject(snackbar,
By.res("android", RESOURCE_ID_SAVE_BUTTON_NO), timeout);
assertWithMessage("wrong text on negative button")
.that(negativeButton.getText().toUpperCase()).isEqualTo(expectedNegativeButtonText);
final String expectedAccessibilityTitle =
getString(RESOURCE_STRING_SAVE_SNACKBAR_ACCESSIBILITY_TITLE);
assertAccessibilityTitle(snackbar, expectedAccessibilityTitle);
return snackbar;
}
/**
* Taps an option in the save snackbar.
*
* @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
* @param types expected types of save info.
*/
void saveForAutofill(boolean yesDoIt, int... types) throws Exception {
final UiObject2 saveSnackBar = assertSaveShowing(
SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, null, types);
saveForAutofill(saveSnackBar, yesDoIt);
}
/**
* Taps an option in the save snackbar.
*
* @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
* @param types expected types of save info.
*/
void saveForAutofill(int negativeButtonStyle, boolean yesDoIt, int... types) throws Exception {
final UiObject2 saveSnackBar = assertSaveShowing(negativeButtonStyle,null, types);
saveForAutofill(saveSnackBar, yesDoIt);
}
/**
* Taps an option in the save snackbar.
*
* @param saveSnackBar Save snackbar, typically obtained through
* {@link #assertSaveShowing(int)}.
* @param yesDoIt {@code true} for 'YES', {@code false} for 'NO THANKS'.
*/
void saveForAutofill(UiObject2 saveSnackBar, boolean yesDoIt) {
final String id = yesDoIt ? "autofill_save_yes" : "autofill_save_no";
final UiObject2 button = saveSnackBar.findObject(By.res("android", id));
assertWithMessage("save button (%s)", id).that(button).isNotNull();
button.click();
}
/**
* Gets the AUTOFILL contextual menu by long pressing a text field.
*
* <p><b>NOTE:</b> this method should only be called in scenarios where we explicitly want to
* test the overflow menu. For all other scenarios where we want to test manual autofill, it's
* better to call {@code AFM.requestAutofill()} directly, because it's less error-prone and
* faster.
*
* @param id resource id of the field.
*/
UiObject2 getAutofillMenuOption(String id) throws Exception {
final UiObject2 field = waitForObject(By.res(mPackageName, id));
// TODO: figure out why obj.longClick() doesn't always work
field.click(3000);
final List<UiObject2> menuItems = waitForObjects(
By.res("android", RESOURCE_ID_CONTEXT_MENUITEM), mDefaultTimeout);
final String expectedText = getAutofillContextualMenuTitle();
final StringBuffer menuNames = new StringBuffer();
for (UiObject2 menuItem : menuItems) {
final String menuName = menuItem.getText();
if (menuName.equalsIgnoreCase(expectedText)) {
return menuItem;
}
menuNames.append("'").append(menuName).append("' ");
}
throw new RetryableException("no '%s' on '%s'", expectedText, menuNames);
}
String getAutofillContextualMenuTitle() {
return getString(RESOURCE_STRING_AUTOFILL);
}
/**
* Gets a string from the Android resources.
*/
private String getString(String id) {
final Resources resources = mContext.getResources();
final int stringId = resources.getIdentifier(id, "string", "android");
return resources.getString(stringId);
}
/**
* Gets a string from the Android resources.
*/
private String getString(String id, Object... formatArgs) {
final Resources resources = mContext.getResources();
final int stringId = resources.getIdentifier(id, "string", "android");
return resources.getString(stringId, formatArgs);
}
/**
* Waits for and returns an object.
*
* @param selector {@link BySelector} that identifies the object.
*/
private UiObject2 waitForObject(BySelector selector) throws Exception {
return waitForObject(selector, mDefaultTimeout);
}
/**
* Waits for and returns an object.
*
* @param parent where to find the object (or {@code null} to use device's root).
* @param selector {@link BySelector} that identifies the object.
* @param timeout timeout in ms.
* @param dumpOnError whether the window hierarchy should be dumped if the object is not found.
*/
private UiObject2 waitForObject(UiObject2 parent, BySelector selector, Timeout timeout,
boolean dumpOnError) throws Exception {
// NOTE: mDevice.wait does not work for the save snackbar, so we need a polling approach.
try {
return timeout.run("waitForObject(" + selector + ")", () -> {
return parent != null
? parent.findObject(selector)
: mDevice.findObject(selector);
});
} catch (RetryableException e) {
if (dumpOnError) {
dumpScreen("waitForObject() for " + selector + "failed");
}
throw e;
}
}
private UiObject2 waitForObject(UiObject2 parent, BySelector selector, Timeout timeout)
throws Exception {
return waitForObject(parent, selector, timeout, DUMP_ON_ERROR);
}
/**
* Waits for and returns an object.
*
* @param selector {@link BySelector} that identifies the object.
* @param timeout timeout in ms
*/
private UiObject2 waitForObject(BySelector selector, Timeout timeout) throws Exception {
return waitForObject(null, selector, timeout);
}
/**
* Execute a Runnable and wait for TYPE_WINDOWS_CHANGED or TYPE_WINDOW_STATE_CHANGED.
* TODO: No longer need Retry, Refactoring the Timeout (e.g. we probably need two values:
* one large timeout value that expects window event, one small value that expect no window
* event)
*/
public void waitForWindowChange(Runnable runnable, long timeoutMillis) throws TimeoutException {
mAutoman.executeAndWaitForEvent(runnable, (AccessibilityEvent event) -> {
switch (event.getEventType()) {
case AccessibilityEvent.TYPE_WINDOWS_CHANGED:
case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
return true;
}
return false;
}, timeoutMillis);
}
/**
* Waits for and returns a list of objects.
*
* @param selector {@link BySelector} that identifies the object.
* @param timeout timeout in ms
*/
private List<UiObject2> waitForObjects(BySelector selector, Timeout timeout) throws Exception {
// NOTE: mDevice.wait does not work for the save snackbar, so we need a polling approach.
try {
return timeout.run("waitForObject(" + selector + ")", () -> {
final List<UiObject2> uiObjects = mDevice.findObjects(selector);
if (uiObjects != null && !uiObjects.isEmpty()) {
return uiObjects;
}
return null;
});
} catch (RetryableException e) {
dumpScreen("waitForObjects() for " + selector + "failed");
throw e;
}
}
private UiObject2 findDatasetPicker(Timeout timeout) throws Exception {
final UiObject2 picker = waitForObject(DATASET_PICKER_SELECTOR, timeout);
final String expectedTitle = getString(RESOURCE_STRING_DATASET_PICKER_ACCESSIBILITY_TITLE);
assertAccessibilityTitle(picker, expectedTitle);
if (picker != null) {
mOkToCallAssertNoDatasets = true;
}
return picker;
}
/**
* Asserts a given object has the expected accessibility title.
*/
private void assertAccessibilityTitle(UiObject2 object, String expectedTitle) {
// TODO: ideally it should get the AccessibilityWindowInfo from the object, but UiAutomator
// does not expose that.
for (AccessibilityWindowInfo window : mAutoman.getWindows()) {
final CharSequence title = window.getTitle();
if (title != null && title.toString().equals(expectedTitle)) {
return;
}
}
throw new RetryableException("Title '%s' not found for %s", expectedTitle, object);
}
/**
* Sets the the screen orientation.
*
* @param orientation typically {@link #LANDSCAPE} or {@link #PORTRAIT}.
*
* @throws RetryableException if value didn't change.
*/
public void setScreenOrientation(int orientation) throws Exception {
mAutoman.setRotation(orientation);
UI_SCREEN_ORIENTATION_TIMEOUT.run("setScreenOrientation(" + orientation + ")", () -> {
return getScreenOrientation() == orientation ? Boolean.TRUE : null;
});
}
/**
* Gets the value of the screen orientation.
*
* @return typically {@link #LANDSCAPE} or {@link #PORTRAIT}.
*/
public int getScreenOrientation() {
return mDevice.getDisplayRotation();
}
/**
* Dumps the current view hierarchy int the output stream.
*/
public void dumpScreen(String cause) {
new Exception("dumpScreen(cause=" + cause + ") stacktrace").printStackTrace(System.out);
try {
try (ByteArrayOutputStream os = new ByteArrayOutputStream()) {
mDevice.dumpWindowHierarchy(os);
os.flush();
Log.w(TAG, "Dumping window hierarchy because " + cause);
for (String line : os.toString("UTF-8").split("\n")) {
Log.w(TAG, line);
// Sleep a little bit to avoid logs being ignored due to spam
SystemClock.sleep(100);
}
}
} catch (IOException e) {
// Just ignore it...
Log.e(TAG, "exception dumping window hierarchy", e);
return;
}
}
// TODO(b/74358143): ideally we should take a screenshot limited by the boundaries of the
// activity window, so external elements (such as the clock) are filtered out and don't cause
// test flakiness when the contents are compared.
public Bitmap takeScreenshot() {
final long before = SystemClock.elapsedRealtime();
final Bitmap bitmap = mAutoman.takeScreenshot();
final long delta = SystemClock.elapsedRealtime() - before;
Log.v(TAG, "Screenshot taken in " + delta + "ms");
return bitmap;
}
/**
* Asserts the contents of a child element.
*
* @param parent parent object
* @param childId (relative) resource id of the child
* @param assertion if {@code null}, asserts the child does not exist; otherwise, asserts the
* child with it.
*/
public void assertChild(@NonNull UiObject2 parent, @NonNull String childId,
@Nullable Visitor<UiObject2> assertion) {
final UiObject2 child = parent.findObject(By.res(mPackageName, childId));
if (assertion != null) {
assertWithMessage("Didn't find child with id '%s'", childId).that(child).isNotNull();
try {
assertion.visit(child);
} catch (Throwable t) {
throw new AssertionError("Error on child '" + childId + "'", t);
}
} else {
assertWithMessage("Shouldn't find child with id '%s'", childId).that(child).isNull();
}
}
/**
* Waits until the window was split to show multiple activities.
*/
public void waitForWindowSplit() throws Exception {
try {
assertShownById(SPLIT_WINDOW_DIVIDER_ID);
} catch (Exception e) {
final long timeout = Timeouts.ACTIVITY_RESURRECTION.ms();
Log.e(TAG, "Did not find window divider " + SPLIT_WINDOW_DIVIDER_ID + "; waiting "
+ timeout + "ms instead");
SystemClock.sleep(timeout);
}
}
private boolean getBoolean(String id) {
final Resources resources = mContext.getResources();
final int booleanId = resources.getIdentifier(id, "bool", "android");
return resources.getBoolean(booleanId);
}
}