| /* |
| * 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.android.compatibility.common.util.ShellUtils.runShellCommand; |
| |
| import static com.google.common.truth.Truth.assertThat; |
| import static com.google.common.truth.Truth.assertWithMessage; |
| |
| import static org.junit.Assume.assumeTrue; |
| |
| 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.SearchCondition; |
| 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.platform.app.InstrumentationRegistry; |
| |
| import com.android.compatibility.common.util.RetryableException; |
| import com.android.compatibility.common.util.Timeout; |
| |
| import java.io.File; |
| import java.io.FileInputStream; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.List; |
| import java.util.concurrent.TimeoutException; |
| |
| /** |
| * Helper for UI-related needs. |
| */ |
| public 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_ID_SAVE_BUTTON_YES = "autofill_save_yes"; |
| private static final String RESOURCE_ID_OVERFLOW = "overflow"; |
| |
| 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_SAVE_BUTTON_YES = "autofill_save_yes"; |
| private static final String RESOURCE_STRING_UPDATE_BUTTON_YES = "autofill_update_yes"; |
| private static final String RESOURCE_STRING_UPDATE_TITLE = "autofill_update_title"; |
| private static final String RESOURCE_STRING_UPDATE_TITLE_WITH_TYPE = |
| "autofill_update_title_with_type"; |
| |
| 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; |
| |
| public UiBot() { |
| this(UI_TIMEOUT); |
| } |
| |
| public UiBot(Timeout defaultTimeout) { |
| mDefaultTimeout = defaultTimeout; |
| final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); |
| mDevice = UiDevice.getInstance(instrumentation); |
| mContext = instrumentation.getContext(); |
| mPackageName = mContext.getPackageName(); |
| mAutoman = instrumentation.getUiAutomation(); |
| } |
| |
| public void waitForIdle() { |
| final long before = SystemClock.elapsedRealtimeNanos(); |
| mDevice.waitForIdle(); |
| final float delta = ((float) (SystemClock.elapsedRealtimeNanos() - before)) / 1_000_000; |
| Log.v(TAG, "device idle in " + delta + "ms"); |
| } |
| |
| public void reset() { |
| mOkToCallAssertNoDatasets = false; |
| } |
| |
| /** |
| * Assumes the device has a minimum height and width of {@code minSize}, throwing a |
| * {@code AssumptionViolatedException} if it doesn't (so the test is skiped by the JUnit |
| * Runner). |
| */ |
| public void assumeMinimumResolution(int minSize) { |
| final int width = mDevice.getDisplayWidth(); |
| final int heigth = mDevice.getDisplayHeight(); |
| final int min = Math.min(width, heigth); |
| assumeTrue("Screen size is too small (" + width + "x" + heigth + ")", min >= minSize); |
| Log.d(TAG, "assumeMinimumResolution(" + minSize + ") passed: screen size is " |
| + width + "x" + heigth); |
| } |
| |
| /** |
| * Sets the screen resolution in a way that the IME doesn't interfere with the Autofill UI |
| * when the device is rotated to landscape. |
| * |
| * When called, test must call <p>{@link #resetScreenResolution()} in a {@code finally} block. |
| * |
| * @deprecated this method should not be necessarily anymore as we're using a MockIme. |
| */ |
| @Deprecated |
| // TODO: remove once we're sure no more OEM is getting failure due to screen size |
| public void setScreenResolution() { |
| if (true) { |
| Log.w(TAG, "setScreenResolution(): ignored"); |
| return; |
| } |
| assumeMinimumResolution(500); |
| |
| runShellCommand("wm size 1080x1920"); |
| runShellCommand("wm density 320"); |
| } |
| |
| /** |
| * Resets the screen resolution. |
| * |
| * <p>Should always be called after {@link #setScreenResolution()}. |
| * |
| * @deprecated this method should not be necessarily anymore as we're using a MockIme. |
| */ |
| @Deprecated |
| // TODO: remove once we're sure no more OEM is getting failure due to screen size |
| public void resetScreenResolution() { |
| if (true) { |
| Log.w(TAG, "resetScreenResolution(): ignored"); |
| return; |
| } |
| runShellCommand("wm density reset"); |
| runShellCommand("wm size reset"); |
| } |
| |
| /** |
| * 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public 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)}. |
| */ |
| public 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; |
| } |
| |
| /** |
| * Finds a node by text, without waiting for it to be shown (but failing if it isn't). |
| */ |
| @NonNull |
| public UiObject2 findRightAwayByText(@NonNull String text) throws Exception { |
| final UiObject2 object = mDevice.findObject(By.text(text)); |
| assertWithMessage("no UIObject for 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("Found 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. |
| */ |
| public boolean hasViewWithText(String name) { |
| Log.v(TAG, "hasViewWithText(): " + name); |
| |
| return mDevice.findObject(By.text(name)) != null; |
| } |
| |
| /** |
| * Selects a view by id. |
| */ |
| public 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. |
| */ |
| public 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. |
| */ |
| public UiObject2 assertShownByRelativeId(String id) throws Exception { |
| return assertShownByRelativeId(id, mDefaultTimeout); |
| } |
| |
| public 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. |
| */ |
| public void assertGoneByRelativeId(@NonNull String id, @NonNull Timeout timeout) { |
| assertGoneByRelativeId(/* parent = */ null, id, timeout); |
| } |
| |
| public void assertGoneByRelativeId(int resId, @NonNull Timeout timeout) { |
| assertGoneByRelativeId(/* parent = */ null, getIdName(resId), timeout); |
| } |
| |
| private String getIdName(int resId) { |
| return mContext.getResources().getResourceEntryName(resId); |
| } |
| |
| /** |
| * Asserts the id is not shown on the parent 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. |
| */ |
| public void assertGoneByRelativeId(@Nullable UiObject2 parent, @NonNull String id, |
| @NonNull Timeout timeout) { |
| final SearchCondition<Boolean> condition = Until.gone(By.res(mPackageName, id)); |
| final boolean gone = parent != null |
| ? parent.wait(condition, timeout.ms()) |
| : mDevice.wait(condition, timeout.ms()); |
| if (!gone) { |
| final String message = "Object with id '" + id + "' should be gone after " |
| + timeout + " ms"; |
| dumpScreen(message); |
| throw new RetryableException(message); |
| } |
| } |
| |
| public UiObject2 assertShownByRelativeId(int resId) throws Exception { |
| return assertShownByRelativeId(getIdName(resId)); |
| } |
| |
| public void assertNeverShownByRelativeId(@NonNull String description, int resId, long timeout) |
| throws Exception { |
| final BySelector selector = By.res(Helper.MY_PACKAGE, getIdName(resId)); |
| assertNeverShown(description, selector, timeout); |
| } |
| |
| /** |
| * 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. |
| */ |
| public String getTextByRelativeId(String id) throws Exception { |
| return waitForObject(By.res(mPackageName, id)).getText(); |
| } |
| |
| /** |
| * Focus in the view with the given resource id. |
| */ |
| public void focusByRelativeId(String id) throws Exception { |
| waitForObject(By.res(mPackageName, id)).click(); |
| } |
| |
| /** |
| * Sets a new text on a view. |
| */ |
| public void setTextByRelativeId(String id, String newText) throws Exception { |
| waitForObject(By.res(mPackageName, id)).setText(newText); |
| } |
| |
| /** |
| * Asserts the save snackbar is showing and returns it. |
| */ |
| public UiObject2 assertSaveShowing(int type) throws Exception { |
| return assertSaveShowing(SAVE_TIMEOUT, type); |
| } |
| |
| /** |
| * Asserts the save snackbar is showing and returns it. |
| */ |
| public UiObject2 assertSaveShowing(Timeout timeout, int type) throws Exception { |
| return assertSaveShowing(null, timeout, type); |
| } |
| |
| /** |
| * Asserts the save snackbar is showing with the Update message and returns it. |
| */ |
| public UiObject2 assertUpdateShowing(int... types) throws Exception { |
| return assertSaveOrUpdateShowing(/* update= */ true, SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, |
| null, SAVE_TIMEOUT, types); |
| } |
| |
| /** |
| * Presses the Back button. |
| */ |
| public void pressBack() { |
| Log.d(TAG, "pressBack()"); |
| mDevice.pressBack(); |
| } |
| |
| /** |
| * Presses the Home button. |
| */ |
| public void pressHome() { |
| Log.d(TAG, "pressHome()"); |
| mDevice.pressHome(); |
| } |
| |
| /** |
| * Asserts the save snackbar is not showing. |
| */ |
| public void assertSaveNotShowing(int type) throws Exception { |
| assertNeverShown("save UI for type " + type, SAVE_UI_SELECTOR, SAVE_NOT_SHOWN_NAPTIME_MS); |
| } |
| |
| public void assertSaveNotShowing() throws Exception { |
| assertNeverShown("save UI", 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); |
| } |
| |
| public UiObject2 assertSaveShowing(String description, int... types) throws Exception { |
| return assertSaveOrUpdateShowing(/* update= */ false, SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, |
| description, SAVE_TIMEOUT, types); |
| } |
| |
| public UiObject2 assertSaveShowing(String description, Timeout timeout, int... types) |
| throws Exception { |
| return assertSaveOrUpdateShowing(/* update= */ false, SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, |
| description, timeout, types); |
| } |
| |
| public UiObject2 assertSaveShowing(int negativeButtonStyle, String description, |
| int... types) throws Exception { |
| return assertSaveOrUpdateShowing(/* update= */ false, negativeButtonStyle, description, |
| SAVE_TIMEOUT, types); |
| } |
| |
| |
| public UiObject2 assertSaveOrUpdateShowing(boolean update, 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 titleId, titleWithTypeId; |
| if (update) { |
| titleId = RESOURCE_STRING_UPDATE_TITLE; |
| titleWithTypeId = RESOURCE_STRING_UPDATE_TITLE_WITH_TYPE; |
| } else { |
| titleId = RESOURCE_STRING_SAVE_TITLE; |
| titleWithTypeId = RESOURCE_STRING_SAVE_TITLE_WITH_TYPE; |
| } |
| |
| final String serviceLabel = InstrumentedAutoFillService.getServiceLabel(); |
| switch (types.length) { |
| case 1: |
| final String expectedTitle = (types[0] == SAVE_DATA_TYPE_GENERIC) |
| ? Html.fromHtml(getString(titleId, serviceLabel), 0).toString() |
| : Html.fromHtml(getString(titleWithTypeId, |
| 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 positiveButtonStringId = update ? RESOURCE_STRING_UPDATE_BUTTON_YES |
| : RESOURCE_STRING_SAVE_BUTTON_YES; |
| final String expectedPositiveButtonText = getString(positiveButtonStringId).toUpperCase(); |
| final UiObject2 positiveButton = waitForObject(snackbar, |
| By.res("android", RESOURCE_ID_SAVE_BUTTON_YES), timeout); |
| assertWithMessage("wrong text on positive button") |
| .that(positiveButton.getText().toUpperCase()).isEqualTo(expectedPositiveButtonText); |
| |
| 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. |
| */ |
| public void saveForAutofill(boolean yesDoIt, int... types) throws Exception { |
| final UiObject2 saveSnackBar = assertSaveShowing( |
| SaveInfo.NEGATIVE_BUTTON_STYLE_CANCEL, null, types); |
| saveForAutofill(saveSnackBar, yesDoIt); |
| } |
| |
| public void updateForAutofill(boolean yesDoIt, int... types) throws Exception { |
| final UiObject2 saveUi = assertUpdateShowing(types); |
| saveForAutofill(saveUi, 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. |
| */ |
| public 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'. |
| */ |
| public 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. |
| */ |
| public 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); |
| |
| List<UiObject2> menuItems = waitForObjects( |
| By.res("android", RESOURCE_ID_CONTEXT_MENUITEM), mDefaultTimeout); |
| final String expectedText = getAutofillContextualMenuTitle(); |
| |
| final StringBuffer menuNames = new StringBuffer(); |
| |
| // Check first menu for AUTOFILL |
| for (UiObject2 menuItem : menuItems) { |
| final String menuName = menuItem.getText(); |
| if (menuName.equalsIgnoreCase(expectedText)) { |
| Log.v(TAG, "AUTOFILL found in first menu"); |
| return menuItem; |
| } |
| menuNames.append("'").append(menuName).append("' "); |
| } |
| |
| menuNames.append(";"); |
| |
| // First menu does not have AUTOFILL, check overflow |
| final BySelector overflowSelector = By.res("android", RESOURCE_ID_OVERFLOW); |
| |
| // Click overflow menu button. |
| final UiObject2 overflowMenu = waitForObject(overflowSelector, mDefaultTimeout); |
| overflowMenu.click(); |
| |
| // Wait for overflow menu to show. |
| mDevice.wait(Until.gone(overflowSelector), 1000); |
| |
| menuItems = waitForObjects( |
| By.res("android", RESOURCE_ID_CONTEXT_MENUITEM), mDefaultTimeout); |
| for (UiObject2 menuItem : menuItems) { |
| final String menuName = menuItem.getText(); |
| if (menuName.equalsIgnoreCase(expectedText)) { |
| Log.v(TAG, "AUTOFILL found in overflow menu"); |
| 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 + "on " |
| + (parent == null ? "mDevice" : parent) + " failed"); |
| } |
| throw e; |
| } |
| } |
| |
| public UiObject2 waitForObject(@Nullable UiObject2 parent, @NonNull BySelector selector, |
| @NonNull 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(@NonNull BySelector selector, @NonNull Timeout timeout) |
| throws Exception { |
| return waitForObject(/* parent= */ null, selector, timeout); |
| } |
| |
| /** |
| * Waits for and returns a child from a parent {@link UiObject2}. |
| */ |
| public UiObject2 assertChildText(UiObject2 parent, String resourceId, String expectedText) |
| throws Exception { |
| final UiObject2 child = waitForObject(parent, By.res(mPackageName, resourceId), |
| Timeouts.UI_TIMEOUT); |
| assertWithMessage("wrong text for view '%s'", resourceId).that(child.getText()) |
| .isEqualTo(expectedText); |
| return child; |
| } |
| |
| /** |
| * Execute a Runnable and wait for {@link AccessibilityEvent#TYPE_WINDOWS_CHANGED} or |
| * {@link AccessibilityEvent#TYPE_WINDOW_STATE_CHANGED}. |
| */ |
| public AccessibilityEvent waitForWindowChange(Runnable runnable, long timeoutMillis) { |
| try { |
| return mAutoman.executeAndWaitForEvent(runnable, (AccessibilityEvent event) -> { |
| switch (event.getEventType()) { |
| case AccessibilityEvent.TYPE_WINDOWS_CHANGED: |
| case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED: |
| return true; |
| default: |
| Log.v(TAG, "waitForWindowChange(): ignoring event " + event); |
| } |
| return false; |
| }, timeoutMillis); |
| } catch (TimeoutException e) { |
| throw new WindowChangeTimeoutException(e, timeoutMillis); |
| } |
| } |
| |
| public AccessibilityEvent waitForWindowChange(Runnable runnable) { |
| return waitForWindowChange(runnable, Timeouts.WINDOW_CHANGE_TIMEOUT_MS); |
| } |
| |
| /** |
| * 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 and take a screenshot and save both locally so they can be |
| * inspected later. |
| */ |
| public void dumpScreen(@NonNull String cause) { |
| try { |
| final File file = Helper.createTestFile("hierarchy.xml"); |
| if (file == null) return; |
| Log.w(TAG, "Dumping window hierarchy because " + cause + " on " + file); |
| try (FileInputStream fis = new FileInputStream(file)) { |
| mDevice.dumpWindowHierarchy(file); |
| } |
| } catch (Exception e) { |
| Log.e(TAG, "error dumping screen on " + cause, e); |
| } finally { |
| takeScreenshotAndSave(); |
| } |
| } |
| |
| // 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; |
| } |
| |
| /** |
| * Takes a screenshot and save it in the file system for post-mortem analysis. |
| */ |
| public void takeScreenshotAndSave() { |
| File file = null; |
| try { |
| file = Helper.createTestFile("screenshot.png"); |
| if (file != null) { |
| Log.i(TAG, "Taking screenshot on " + file); |
| final Bitmap screenshot = takeScreenshot(); |
| Helper.dumpBitmap(screenshot, file); |
| } |
| } catch (Exception e) { |
| Log.e(TAG, "Error taking screenshot and saving on " + file, e); |
| } |
| } |
| |
| /** |
| * 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)); |
| try { |
| 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(); |
| } |
| } catch (RuntimeException | Error e) { |
| dumpScreen("assertChild(" + childId + ") failed: " + e); |
| throw e; |
| } |
| } |
| |
| /** |
| * 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); |
| } |
| } |
| } |