diff options
Diffstat (limited to 'espresso/espresso-lib/src/main/java/com/google')
72 files changed, 9485 insertions, 0 deletions
diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java new file mode 100644 index 0000000..41c3678 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; + +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * An exception which indicates that a Matcher<View> matched multiple views in the hierarchy when + * only one view was expected. It should be called only from the main thread. + * <p> + * Contains details about the matcher and the current view hierarchy to aid in debugging. + * </p> + * <p> + * Since this is usually an unrecoverable error this exception is a runtime exception. + * </p> + * <p> + * References to the view and failing matcher are purposefully not included in the state of this + * object - since it will most likely be created on the UI thread and thrown on the instrumentation + * thread, it would be invalid to touch the view on the instrumentation thread. Also the view + * hierarchy may have changed since exception creation (leading to more confusion). + * </p> + */ +public final class AmbiguousViewMatcherException extends RuntimeException + implements EspressoException { + + private Matcher<? super View> viewMatcher; + private View rootView; + private View view1; + private View view2; + private View[] others; + + private AmbiguousViewMatcherException(String description) { + super(description); + } + + private AmbiguousViewMatcherException(Builder builder) { + super(getErrorMessage(builder)); + this.viewMatcher = builder.viewMatcher; + this.rootView = builder.rootView; + this.view1 = builder.view1; + this.view2 = builder.view2; + this.others = builder.others; + } + + private static String getErrorMessage(Builder builder) { + String errorMessage = ""; + if (builder.includeViewHierarchy) { + ImmutableSet<View> ambiguousViews = + ImmutableSet.<View>builder().add(builder.view1, builder.view2).add(builder.others).build(); + errorMessage = HumanReadables.getViewHierarchyErrorMessage(builder.rootView, + Lists.newArrayList(ambiguousViews), + String.format("'%s' matches multiple views in the hierarchy.", builder.viewMatcher), + "****MATCHES****"); + } else { + errorMessage = String.format("Multiple Ambiguous Views found for matcher %s", + builder.viewMatcher); + } + return errorMessage; + } + + /** Builder for {@link AmbiguousViewMatcherException}. */ + public static class Builder { + private Matcher<? super View> viewMatcher; + private View rootView; + private View view1; + private View view2; + private View[] others; + private boolean includeViewHierarchy = true; + + public Builder from(AmbiguousViewMatcherException exception) { + this.viewMatcher = exception.viewMatcher; + this.rootView = exception.rootView; + this.view1 = exception.view1; + this.view2 = exception.view2; + this.others = exception.others; + return this; + } + + public Builder withViewMatcher(Matcher<? super View> viewMatcher) { + this.viewMatcher = viewMatcher; + return this; + } + + public Builder withRootView(View rootView) { + this.rootView = rootView; + return this; + } + + public Builder withView1(View view1) { + this.view1 = view1; + return this; + } + + public Builder withView2(View view2) { + this.view2 = view2; + return this; + } + + public Builder withOtherAmbiguousViews(View... others) { + this.others = others; + return this; + } + + public Builder includeViewHierarchy(boolean includeViewHierarchy) { + this.includeViewHierarchy = includeViewHierarchy; + return this; + } + + public AmbiguousViewMatcherException build() { + checkNotNull(viewMatcher); + checkNotNull(rootView); + checkNotNull(view1); + checkNotNull(view2); + checkNotNull(others); + return new AmbiguousViewMatcherException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml new file mode 100644 index 0000000..e32c392 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright (C) 2014 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. + --> + +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package="com.google.android.apps.common.testing.ui.espresso" > + + <uses-sdk + android:minSdkVersion="7" + android:targetSdkVersion="17" /> + + <application android:label="Espresso" /> + +</manifest>
\ No newline at end of file diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java new file mode 100644 index 0000000..e84fcca --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java @@ -0,0 +1,75 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.common.base.Joiner; + +import android.os.Looper; + +import java.util.List; + +/** + * An exception which indicates that the App has not become idle even after the specified duration. + */ +public final class AppNotIdleException extends RuntimeException implements EspressoException { + + private AppNotIdleException(String description) { + super(description); + } + + /** + * Creates a new AppNotIdleException suitable for erroring out a test case. + * + * This should be called only from the main thread if the app does not idle out within the + * specified duration. + * + * @param idleConditions list of idleConditions that failed to become idle. + * @param loopCount number of times it was tried to check if they became idle. + * @param seconds number of seconds that was tried before giving up. + * + * @return a AppNotIdleException suitable to be thrown on the instrumentation thread. + */ + @Deprecated + public static AppNotIdleException create(List<String> idleConditions, int loopCount, + int seconds) { + checkState(Looper.myLooper() == Looper.getMainLooper()); + String errorMessage = String.format("App not idle within timeout of %s seconds even" + + "after trying for %s iterations. The following Idle Conditions failed %s", + seconds, loopCount, Joiner.on(",").join(idleConditions)); + return new AppNotIdleException(errorMessage); + } + + /** + * Creates a new AppNotIdleException suitable for erroring out a test case. + * + * This should be called only from the main thread if the app does not idle out within the + * specified duration. + * + * @param idleConditions list of idleConditions that failed to become idle. + * @param message a message about the failure. + * + * @return a AppNotIdleException suitable to be thrown on the instrumentation thread. + */ + static AppNotIdleException create(List<String> idleConditions, String message) { + String errorMessage = String.format("%s The following Idle Conditions failed %s.", + message, Joiner.on(",").join(idleConditions)); + return new AppNotIdleException(errorMessage); + } +} + diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java new file mode 100644 index 0000000..aa13d5e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java @@ -0,0 +1,207 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + + +import static com.google.android.apps.common.testing.ui.espresso.Espresso.onView; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.action.AdapterDataLoaderAction; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocol; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocol.AdaptedData; +import com.google.android.apps.common.testing.ui.espresso.action.AdapterViewProtocols; +import com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.ViewParent; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * An interface to interact with data displayed in AdapterViews. + * <p> + * This interface builds on top of {@link ViewInteraction} and should be the preferred way to + * interact with elements displayed inside AdapterViews. + * </p> + * <p> + * This is necessary because an AdapterView may not load all the data held by its Adapter into the + * view hierarchy until a user interaction makes it necessary. Also it is more fluent / less brittle + * to match upon the data object being rendered into the display then the rendering itself. + * </p> + * <p> + * By default, a DataInteraction takes place against any AdapterView found within the current + * screen, if you have multiple AdapterView objects displayed, you will need to narrow the selection + * by using the inAdapterView method. + * </p> + * <p> + * The check and perform method operate on the top level child of the adapter view, if you need to + * operate on a subview (eg: a Button within the list) use the onChildView method before calling + * perform or check. + * </p> + * + */ +public class DataInteraction { + + private final Matcher<Object> dataMatcher; + private Matcher<View> adapterMatcher = isAssignableFrom(AdapterView.class); + private Optional<Matcher<View>> childViewMatcher = Optional.absent(); + private Optional<Integer> atPosition = Optional.absent(); + private AdapterViewProtocol adapterViewProtocol = AdapterViewProtocols.standardProtocol(); + private Matcher<Root> rootMatcher = RootMatchers.DEFAULT; + + DataInteraction(Matcher<Object> dataMatcher) { + this.dataMatcher = checkNotNull(dataMatcher); + } + + /** + * Causes perform and check methods to take place on a specific child view of the view returned + * by Adapter.getView() + */ + public DataInteraction onChildView(Matcher<View> childMatcher) { + this.childViewMatcher = Optional.of(checkNotNull(childMatcher)); + return this; + } + + /** + * Causes this data interaction to work within the Root specified by the given root matcher. + */ + public DataInteraction inRoot(Matcher<Root> rootMatcher) { + this.rootMatcher = checkNotNull(rootMatcher); + return this; + } + + /** + * Selects a particular adapter view to operate on, by default we operate on any adapter view + * on the screen. + */ + public DataInteraction inAdapterView(Matcher<View> adapterMatcher) { + this.adapterMatcher = checkNotNull(adapterMatcher); + return this; + } + + /** + * Selects the view which matches the nth position on the adapter + * based on the data matcher. + */ + public DataInteraction atPosition(Integer atPosition) { + this.atPosition = Optional.of(checkNotNull(atPosition)); + return this; + } + + /** + * Use a different AdapterViewProtocol if the Adapter implementation does not + * satisfy the AdapterView contract like (@code ExpandableListView) + */ + public DataInteraction usingAdapterViewProtocol(AdapterViewProtocol adapterViewProtocol) { + this.adapterViewProtocol = checkNotNull(adapterViewProtocol); + return this; + } + + /** + * Performs an action on the view after we force the data to be loaded. + * + * @return an {@link ViewInteraction} for more assertions or actions. + */ + public ViewInteraction perform(ViewAction... actions) { + AdapterDataLoaderAction adapterDataLoaderAction = load(); + + return onView(makeTargetMatcher(adapterDataLoaderAction)) + .inRoot(rootMatcher) + .perform(actions); + } + + /** + * Performs an assertion on the state of the view after we force the data to be loaded. + * + * @return an {@link ViewInteraction} for more assertions or actions. + */ + public ViewInteraction check(ViewAssertion assertion) { + AdapterDataLoaderAction adapterDataLoaderAction = load(); + + return onView(makeTargetMatcher(adapterDataLoaderAction)) + .inRoot(rootMatcher) + .check(assertion); + } + + private AdapterDataLoaderAction load() { + AdapterDataLoaderAction adapterDataLoaderAction = + new AdapterDataLoaderAction(dataMatcher, atPosition, adapterViewProtocol); + onView(adapterMatcher) + .inRoot(rootMatcher) + .perform(adapterDataLoaderAction); + return adapterDataLoaderAction; + } + + @SuppressWarnings("unchecked") + private Matcher<View> makeTargetMatcher(AdapterDataLoaderAction adapterDataLoaderAction) { + Matcher<View> targetView = displayingData(adapterMatcher, dataMatcher, adapterViewProtocol, + adapterDataLoaderAction); + if (childViewMatcher.isPresent()) { + targetView = allOf(childViewMatcher.get(), isDescendantOfA(targetView)); + } + return targetView; + } + + private Matcher<View> displayingData( + final Matcher<View> adapterMatcher, + final Matcher<Object> dataMatcher, + final AdapterViewProtocol adapterViewProtocol, + final AdapterDataLoaderAction adapterDataLoaderAction) { + checkNotNull(adapterMatcher); + checkNotNull(dataMatcher); + checkNotNull(adapterViewProtocol); + + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText(" displaying data matching: "); + dataMatcher.describeTo(description); + description.appendText(" within adapter view matching: "); + adapterMatcher.describeTo(description); + } + + @SuppressWarnings("unchecked") + @Override + public boolean matchesSafely(View view) { + + ViewParent parent = view.getParent(); + + while (parent != null && !(parent instanceof AdapterView)) { + parent = parent.getParent(); + } + + if (parent != null && adapterMatcher.matches(parent)) { + Optional<AdaptedData> data = adapterViewProtocol.getDataRenderedByView( + (AdapterView<? extends Adapter>) parent, view); + if (data.isPresent()) { + return adapterDataLoaderAction.getAdaptedData().opaqueToken.equals( + data.get().opaqueToken); + } + } + return false; + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java new file mode 100644 index 0000000..5e3d5f4 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java @@ -0,0 +1,255 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.click; +import static com.google.android.apps.common.testing.ui.espresso.action.ViewActions.pressMenuKey; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isRoot; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withClassName; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withContentDescription; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.endsWith; + +import com.google.android.apps.common.testing.ui.espresso.action.ViewActions; +import com.google.android.apps.common.testing.ui.espresso.base.BaseLayerModule; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry; +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables; + +import android.content.Context; +import android.os.Build; +import android.os.Looper; +import android.view.View; +import android.view.ViewConfiguration; + +import dagger.ObjectGraph; + +import org.hamcrest.Matcher; + +/** + * Entry point to the Espresso framework. Test authors can initiate testing by using one of the on* + * methods (e.g. onView) or perform top-level user actions (e.g. pressBack). + */ +public final class Espresso { + + static ObjectGraph espressoGraph() { + return GraphHolder.graph(); + } + + private Espresso() {} + + /** + * Creates an {@link PartiallyScopedViewInteraction} for a given view. Note: the view has + * to be part of the view hierarchy. This may not be the case if it is rendered as part of + * an AdapterView (e.g. ListView). If this is the case, use Espresso.onData to load the view + * first. + * + * @param viewMatcher used to select the view. + * @see #onData + */ + public static ViewInteraction onView(final Matcher<View> viewMatcher) { + return espressoGraph().plus(new ViewInteractionModule(viewMatcher)).get(ViewInteraction.class); + } + + + + /** + * Creates an {@link DataInteraction} for a data object displayed by the application. Use this + * method to load (into the view hierarchy) items from AdapterView widgets (e.g. ListView). + * + * @param dataMatcher a matcher used to find the data object. + */ + public static DataInteraction onData(Matcher<Object> dataMatcher) { + return new DataInteraction(dataMatcher); + } + + /** + * Registers a Looper for idle checking with the framework. This is intended for use with + * non-UI thread loopers. + * + * @throws IllegalArgumentException if looper is the main looper. + */ + public static void registerLooperAsIdlingResource(Looper looper) { + registerLooperAsIdlingResource(looper, false); + } + + /** + * Registers a Looper for idle checking with the framework. This is intended for use with + * non-UI thread loopers. + * + * This method allows the caller to consider Thread.State.WAIT to be 'idle'. + * + * This is useful in the case where a looper is sending a message to the UI thread synchronously + * through a wait/notify mechanism. + * + * @throws IllegalArgumentException if looper is the main looper. + */ + public static void registerLooperAsIdlingResource(Looper looper, boolean considerWaitIdle) { + espressoGraph().get(IdlingResourceRegistry.class).registerLooper(looper, considerWaitIdle); + } + + /** + * Registers one or more {@link IdlingResource}s with the framework. It is expected, although not + * strictly required, that this method will be called at test setup time prior to any interaction + * with the application under test. When registering more than one resource, ensure that each has + * a unique name. + */ + public static void registerIdlingResources(IdlingResource... resources) { + checkNotNull(resources); + IdlingResourceRegistry registry = espressoGraph().get(IdlingResourceRegistry.class); + for (IdlingResource resource : resources) { + checkNotNull(resource.getName(), "IdlingResource.getName() should not be null"); + registry.register(resource); + } + } + + /** + * Changes the default {@link FailureHandler} to the given one. + */ + public static void setFailureHandler(FailureHandler failureHandler) { + espressoGraph().get(BaseLayerModule.FailureHandlerHolder.class) + .update(checkNotNull(failureHandler)); + } + + /********************************** Top Level Actions ******************************************/ + + // Ideally, this should be only allOf(isDisplayed(), withContentDescription("More options")) + // But the ActionBarActivity compat lib is missing a content description for this element, so + // we add the class name matcher as another option to find the view. + @SuppressWarnings("unchecked") + private static final Matcher<View> OVERFLOW_BUTTON_MATCHER = anyOf( + allOf(isDisplayed(), withContentDescription("More options")), + allOf(isDisplayed(), withClassName(endsWith("OverflowMenuButton")))); + + + /** + * Closes soft keyboard if open. + */ + public static void closeSoftKeyboard() { + onView(isRoot()).perform(ViewActions.closeSoftKeyboard()); + } + + /** + * Opens the overflow menu displayed in the contextual options of an ActionMode. + * + * This works with both native and SherlockActionBar action modes. + * + * Note the significant difference in UX between ActionMode and ActionBar overflows - ActionMode + * will always present an overflow icon and that icon only responds to clicks. The menu button + * (if present) has no impact on it. + */ + @SuppressWarnings("unchecked") + public static void openContextualActionModeOverflowMenu() { + onView(isRoot()) + .perform(new TransitionBridgingViewAction()); + + onView(OVERFLOW_BUTTON_MATCHER) + .perform(click()); + } + + /** + * Press on the back button. + * + * @throws PerformException if currently displayed activity is root activity, since pressing back + * button would result in application closing. + */ + public static void pressBack() { + onView(isRoot()).perform(ViewActions.pressBack()); + } + + /** + * Opens the overflow menu displayed within an ActionBar. + * + * This works with both native and SherlockActionBar ActionBars. + * + * Note the significant differences of UX between ActionMode and ActionBars with respect to + * overflows. If a hardware menu key is present, the overflow icon is never displayed in + * ActionBars and can only be interacted with via menu key presses. + */ + @SuppressWarnings("unchecked") + public static void openActionBarOverflowOrOptionsMenu(Context context) { + if (context.getApplicationInfo().targetSdkVersion < Build.VERSION_CODES.HONEYCOMB) { + // regardless of the os level of the device, this app will be rendering a menukey + // in the virtual navigation bar (if present) or responding to hardware option keys on + // any activity. + onView(isRoot()) + .perform(pressMenuKey()); + } else if (hasVirtualOverflowButton(context)) { + // If we're using virtual keys - theres a chance we're in mid animation of switching + // between a contextual action bar and the non-contextual action bar. In this case there + // are 2 'More Options' buttons present. Lets wait till that is no longer the case. + onView(isRoot()) + .perform(new TransitionBridgingViewAction()); + + onView(OVERFLOW_BUTTON_MATCHER) + .perform(click()); + } else { + // either a hardware button exists, or we're on a pre-HC os. + onView(isRoot()) + .perform(pressMenuKey()); + } + } + + private static boolean hasVirtualOverflowButton(Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB; + } else { + return !ViewConfiguration.get(context).hasPermanentMenuKey(); + } + } + + /** + * Handles the cases where the app is transitioning between a contextual action bar and a + * non contextual action bar. + */ + private static class TransitionBridgingViewAction implements ViewAction { + @Override + public void perform(UiController controller, View view) { + int loops = 0; + while (isTransitioningBetweenActionBars(view) && loops < 100) { + loops++; + controller.loopMainThreadForAtLeast(50); + } + // if we're not transitioning properly the next viewaction + // will give a decent enough exception. + } + + @Override + public String getDescription() { + return "Handle transition between action bar and action bar context."; + } + + @Override + public Matcher<View> getConstraints() { + return isRoot(); + } + + private boolean isTransitioningBetweenActionBars(View view) { + int actionButtonCount = 0; + for (View child : TreeIterables.breadthFirstViewTraversal(view)) { + if (OVERFLOW_BUTTON_MATCHER.matches(child)) { + actionButtonCount++; + } + } + return actionButtonCount > 1; + } + } + + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java new file mode 100644 index 0000000..4c6a5a2 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +/** + * Used for identifying an exception as coming from the {@link Espresso} framework. + */ +public interface EspressoException {} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java new file mode 100644 index 0000000..e0fb9c0 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import org.hamcrest.Matcher; + + + +/** + * Handles failures that happen during test execution. + */ +public interface FailureHandler { + + /** + * Handle the given error in a manner that makes sense to the environment in which the test is + * executed (e.g. take a screenshot, output extra debug info, etc). Upon handling, most handlers + * will choose to propagate the error. + */ + public void handle(Throwable error, Matcher<View> viewMatcher); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java new file mode 100644 index 0000000..3ee8e55 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.testrunner.UsageTrackerRegistry; +import com.google.android.apps.common.testing.ui.espresso.base.BaseLayerModule; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry; + +import dagger.Module; +import dagger.ObjectGraph; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * Holds Espresso's ObjectGraph. + */ +public final class GraphHolder { + + private static final AtomicReference<GraphHolder> instance = + new AtomicReference<GraphHolder>(null); + + private final ObjectGraph graph; + + private GraphHolder(ObjectGraph graph) { + this.graph = checkNotNull(graph); + } + + static ObjectGraph graph() { + GraphHolder instanceRef = instance.get(); + if (null == instanceRef) { + instanceRef = new GraphHolder(ObjectGraph.create(EspressoModule.class)); + if (instance.compareAndSet(null, instanceRef)) { + UsageTrackerRegistry.getInstance().trackUsage("Espresso"); + return instanceRef.graph; + } else { + return instance.get().graph; + } + } else { + return instanceRef.graph; + } + } + + // moe:begin_strip + public static void initialize(Object... modules) { + checkNotNull(modules); + Object[] allModules = new Object[modules.length + 1]; + allModules[0] = EspressoModule.class; + System.arraycopy(modules, 0, allModules, 1, modules.length); + GraphHolder holder = new GraphHolder(ObjectGraph.create(modules)); + checkState(instance.compareAndSet(null, holder), "Espresso already initialized."); + } + // moe:end_strip + + @Module( + includes = BaseLayerModule.class, + injects = IdlingResourceRegistry.class + ) + static class EspressoModule { + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java new file mode 100644 index 0000000..17fdc8d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.concurrent.TimeUnit; + +/** + * Allows users fine grain control over idling policies. + * + * Espresso's default idling policies are suitable for most usecases - however + * certain execution environments (like the ARM emulator) might be very slow. + * This class allows users the ability to adjust defaults to sensible values + * for their environments. + */ +public final class IdlingPolicies { + + private IdlingPolicies() { } + + private static volatile IdlingPolicy masterIdlingPolicy = new IdlingPolicy.Builder() + .withIdlingTimeout(60) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .throwAppNotIdleException() + .build(); + + + private static volatile IdlingPolicy dynamicIdlingResourceErrorPolicy = new IdlingPolicy.Builder() + .withIdlingTimeout(26) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .throwIdlingResourceTimeoutException() + .build(); + + private static volatile IdlingPolicy dynamicIdlingResourceWarningPolicy = + new IdlingPolicy.Builder() + .withIdlingTimeout(5) + .withIdlingTimeoutUnit(TimeUnit.SECONDS) + .logWarning() + .build(); + + + /** + * Updates the IdlingPolicy used in UiController.loopUntil to detect AppNotIdleExceptions. + * + * @param timeout the timeout before an AppNotIdleException is created. + * @param unit the unit of the timeout value. + */ + public static void setMasterPolicyTimeout(long timeout, TimeUnit unit) { + checkArgument(timeout > 0); + checkNotNull(unit); + masterIdlingPolicy = masterIdlingPolicy.toBuilder() + .withIdlingTimeout(timeout) + .withIdlingTimeoutUnit(unit) + .build(); + } + + /** + * Updates the IdlingPolicy used by IdlingResourceRegistry to determine when IdlingResources + * timeout. + * + * @param timeout the timeout before an IdlingResourceTimeoutException is created. + * @param unit the unit of the timeout value. + */ + public static void setIdlingResourceTimeout(long timeout, TimeUnit unit) { + checkArgument(timeout > 0); + checkNotNull(unit); + dynamicIdlingResourceErrorPolicy = dynamicIdlingResourceErrorPolicy.toBuilder() + .withIdlingTimeout(timeout) + .withIdlingTimeoutUnit(unit) + .build(); + } + + + public static IdlingPolicy getMasterIdlingPolicy() { + return masterIdlingPolicy; + } + + public static IdlingPolicy getDynamicIdlingResourceWarningPolicy() { + return dynamicIdlingResourceWarningPolicy; + } + + public static IdlingPolicy getDynamicIdlingResourceErrorPolicy() { + return dynamicIdlingResourceErrorPolicy; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java new file mode 100644 index 0000000..533afdd --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import android.util.Log; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Allows users to control idling idleTimeouts in Espresso. + */ +public final class IdlingPolicy { + private static final String TAG = "IdlingPolicy"; + private enum ResponseAction { THROW_APP_NOT_IDLE, THROW_IDLE_TIMEOUT, LOG_ERROR }; + + private final long idleTimeout; + private final TimeUnit unit; + private final ResponseAction errorHandler; + + /** + * The amount of time the policy allows a resource to be non-idle. + */ + public long getIdleTimeout(){ + return idleTimeout; + } + + /** + * The unit for {@linkgetIdleTimeout}. + */ + public TimeUnit getIdleTimeoutUnit() { + return unit; + } + + /** + * Invoked when the idle idleTimeout has been exceeded. + * + * @param busyResources the resources that are not idle. + * @param message an additional message to include in an exception. + */ + public void handleTimeout(List<String> busyResources, String message) { + switch (errorHandler) { + case THROW_APP_NOT_IDLE: + throw AppNotIdleException.create(busyResources, message); + case THROW_IDLE_TIMEOUT: + throw new IdlingResourceTimeoutException(busyResources); + case LOG_ERROR: + Log.w(TAG, "These resources are not idle: " + busyResources); + break; + default: + throw new IllegalStateException("should never reach here." + busyResources); + } + } + + Builder toBuilder() { + return new Builder(this); + } + + private IdlingPolicy(Builder builder) { + checkArgument(builder.idleTimeout > 0); + this.idleTimeout = builder.idleTimeout; + this.unit = checkNotNull(builder.unit); + this.errorHandler = checkNotNull(builder.errorHandler); + } + + static class Builder { + private long idleTimeout = -1; + private TimeUnit unit = null; + private ResponseAction errorHandler = null; + + public Builder() { } + + public IdlingPolicy build() { + return new IdlingPolicy(this); + } + + private Builder(IdlingPolicy copy) { + this.idleTimeout = copy.idleTimeout; + this.unit = copy.unit; + this.errorHandler = copy.errorHandler; + } + + public Builder withIdlingTimeout(long idleTimeout) { + this.idleTimeout = idleTimeout; + return this; + } + + public Builder withIdlingTimeoutUnit(TimeUnit unit) { + this.unit = unit; + return this; + } + + public Builder throwAppNotIdleException() { + this.errorHandler = ResponseAction.THROW_APP_NOT_IDLE; + return this; + } + + public Builder throwIdlingResourceTimeoutException() { + this.errorHandler = ResponseAction.THROW_IDLE_TIMEOUT; + return this; + } + + public Builder logWarning() { + this.errorHandler = ResponseAction.LOG_ERROR; + return this; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java new file mode 100644 index 0000000..6a9ec69 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.List; + +/** + * Indicates that an {@link IdlingResource}, which has been registered with the framework, has not + * idled within the allowed time. + * + * Since it is not safe to proceed with test execution while the registered resource is busy (as it + * is likely to cause inconsistent results in the test), this is an unrecoverable error. The test + * author should verify that the {@link IdlingResource} interface has been implemented correctly. + */ +public final class IdlingResourceTimeoutException extends RuntimeException + implements EspressoException { + + public IdlingResourceTimeoutException(List<String> resourceNames) { + super(String.format("Wait for %s to become idle timed out", checkNotNull(resourceNames))); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java new file mode 100644 index 0000000..5e6158e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +/** + * An checked {@link Exception} indicating that event injection failed with a + * {@link SecurityException}. + */ +public final class InjectEventSecurityException extends Exception implements EspressoException { + + public InjectEventSecurityException(String message) { + super(message); + } + + public InjectEventSecurityException(Throwable cause) { + super(cause); + } + + public InjectEventSecurityException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java new file mode 100644 index 0000000..77f1c03 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +/** + * An exception which indicates that there are no activities in stage RESUMED. + */ +public final class NoActivityResumedException extends RuntimeException + implements EspressoException { + public NoActivityResumedException(String description) { + super(description); + } + + public NoActivityResumedException(String description, Throwable cause) { + super(description, cause); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java new file mode 100644 index 0000000..9b02aa6 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java @@ -0,0 +1,41 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import org.hamcrest.Matcher; + +import java.util.List; + +/** + * Indicates that a given matcher did not match any {@link Root}s (windows) from those that are + * currently available. + */ +public final class NoMatchingRootException extends RuntimeException implements EspressoException { + + private NoMatchingRootException(String description) { + super(description); + } + + public static NoMatchingRootException create(Matcher<Root> rootMatcher, List<Root> roots) { + checkNotNull(rootMatcher); + checkNotNull(roots); + return new NoMatchingRootException(String.format( + "Matcher '%s' did not match any of the following roots: %s", rootMatcher, roots)); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java new file mode 100644 index 0000000..984f206 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.view.View; + +import org.hamcrest.Matcher; + +import java.util.List; + +/** + * Indicates that a given matcher did not match any elements in the view hierarchy. + * <p> + * Contains details about the matcher and the current view hierarchy to aid in debugging. + * </p> + * <p> + * Since this is usually an unrecoverable error this exception is a runtime exception. + * </p> + * <p> + * References to the view and failing matcher are purposefully not included in the state of this + * object - since it will most likely be created on the UI thread and thrown on the instrumentation + * thread, it would be invalid to touch the view on the instrumentation thread. Also the view + * hierarchy may have changed since exception creation (leading to more confusion). + * </p> + */ +public final class NoMatchingViewException extends RuntimeException implements EspressoException { + + private Matcher<? super View> viewMatcher; + private View rootView; + private List<View> adapterViews = Lists.newArrayList(); + private boolean includeViewHierarchy = true; + private Optional<String> adapterViewWarning = Optional.<String>absent(); + + private NoMatchingViewException(String description) { + super(description); + } + + private NoMatchingViewException(Builder builder) { + super(getErrorMessage(builder)); + this.viewMatcher = builder.viewMatcher; + this.rootView = builder.rootView; + this.adapterViews = builder.adapterViews; + this.adapterViewWarning = builder.adapterViewWarning; + this.includeViewHierarchy = builder.includeViewHierarchy; + } + + private static String getErrorMessage(Builder builder) { + String errorMessage = ""; + if (builder.includeViewHierarchy) { + String message = String.format("No views in hierarchy found matching: %s", + builder.viewMatcher); + if (builder.adapterViewWarning.isPresent()) { + message = message + builder.adapterViewWarning.get(); + } + errorMessage = HumanReadables.getViewHierarchyErrorMessage(builder.rootView, + null /* problemViews */, + message, + null /* problemViewSuffix */); + } else { + errorMessage = String.format("Could not find a view that matches %s" , builder.viewMatcher); + } + return errorMessage; + } + + /** Builder for {@link NoMatchingViewException}. */ + public static class Builder { + + private Matcher<? super View> viewMatcher; + private View rootView; + private List<View> adapterViews = Lists.newArrayList(); + private boolean includeViewHierarchy = true; + private Optional<String> adapterViewWarning = Optional.<String>absent(); + + public Builder from(NoMatchingViewException exception) { + this.viewMatcher = exception.viewMatcher; + this.rootView = exception.rootView; + this.adapterViews = exception.adapterViews; + this.adapterViewWarning = exception.adapterViewWarning; + this.includeViewHierarchy = exception.includeViewHierarchy; + return this; + } + + public Builder withViewMatcher(Matcher<? super View> viewMatcher) { + this.viewMatcher = viewMatcher; + return this; + } + + public Builder withRootView(View rootView) { + this.rootView = rootView; + return this; + } + + public Builder withAdapterViews(List<View> adapterViews) { + this.adapterViews = adapterViews; + return this; + } + + public Builder includeViewHierarchy(boolean includeViewHierarchy) { + this.includeViewHierarchy = includeViewHierarchy; + return this; + } + + public Builder withAdapterViewWarning(Optional<String> adapterViewWarning) { + this.adapterViewWarning = adapterViewWarning; + return this; + } + + public NoMatchingViewException build() { + checkNotNull(viewMatcher); + checkNotNull(rootView); + checkNotNull(adapterViews); + checkNotNull(adapterViewWarning); + return new NoMatchingViewException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java new file mode 100644 index 0000000..ac18e77 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Indicates that an exception occurred while performing a ViewAction on the UI thread. + * + * A description of the {@link ViewAction}, the view being performed on and the cause are included + * in the error. Note: {@link FailureHandler}s can mutate the exception later to make it more user + * friendly. + * + * This is generally not recoverable so it is thrown on the instrumentation thread. + */ +public final class PerformException extends RuntimeException implements EspressoException { + + private static final String MESSAGE_FORMAT = "Error performing '%s' on view '%s'."; + + private final String actionDescription; + private final String viewDescription; + + private PerformException(Builder builder) { + super(String.format(MESSAGE_FORMAT, builder.actionDescription, builder.viewDescription), + builder.cause); + this.actionDescription = checkNotNull(builder.actionDescription); + this.viewDescription = checkNotNull(builder.viewDescription); + } + + public String getActionDescription() { + return actionDescription; + } + + public String getViewDescription() { + return viewDescription; + } + + /** + * Builder for {@link PerformException}. + */ + public static class Builder { + private String actionDescription; + private String viewDescription; + private Throwable cause; + + public Builder from(PerformException instance) { + this.actionDescription = instance.getActionDescription(); + this.viewDescription = instance.getViewDescription(); + this.cause = instance.getCause(); + return this; + } + + public Builder withActionDescription(String actionDescription) { + this.actionDescription = actionDescription; + return this; + } + + public Builder withViewDescription(String viewDescription) { + this.viewDescription = viewDescription; + return this; + } + + public Builder withCause(Throwable cause) { + this.cause = cause; + return this; + } + + public PerformException build() { + return new PerformException(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java new file mode 100644 index 0000000..0d900de --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Objects.toStringHelper; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Objects.ToStringHelper; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.WindowManager; + +/** + * Represents a root view in the application and optionally the layout params of the window holding + * it. + * + * This class is used internally to determine which view root to run user provided matchers against + * it is not part of the public api. + */ +public final class Root { + private final View decorView; + private final Optional<WindowManager.LayoutParams> windowLayoutParams; + + private Root(Builder builder) { + this.decorView = checkNotNull(builder.decorView); + this.windowLayoutParams = Optional.fromNullable(builder.windowLayoutParams); + } + + public View getDecorView() { + return decorView; + } + + public Optional<WindowManager.LayoutParams> getWindowLayoutParams() { + return windowLayoutParams; + } + + @Override + public String toString() { + ToStringHelper helper = toStringHelper(this) + .add("application-window-token", decorView.getApplicationWindowToken()) + .add("window-token", decorView.getWindowToken()) + .add("has-window-focus", decorView.hasWindowFocus()); + if (windowLayoutParams.isPresent()) { + helper + .add("layout-params-type", windowLayoutParams.get().type) + .add("layout-params-string", windowLayoutParams.get()); + } + helper + .add("decor-view-string", HumanReadables.describe(decorView)); + return helper.toString(); + } + + public static class Builder { + private View decorView; + private WindowManager.LayoutParams windowLayoutParams; + + public Root build() { + return new Root(this); + } + + public Builder withDecorView(View view) { + this.decorView = view; + return this; + } + + public Builder withWindowLayoutParams(WindowManager.LayoutParams windowLayoutParams) { + this.windowLayoutParams = windowLayoutParams; + return this; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java new file mode 100644 index 0000000..cf53d08 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Provides base-level UI operations (such as injection of {@link MotionEvent}s) that can be used to + * build user actions such as clicks, scrolls, swipes, etc. This replaces parts of the android + * Instrumentation class that provides similar functionality. However, it provides a more advanced + * synchronization mechanism for test actions. The key differentiators are: + * <ul> + * <li>test actions are assumed to be called on the main thread + * <li>after a test action is initiated, execution blocks until all messages in the main message + * queue have been cleared. + * </ul> + */ +public interface UiController { + /** + * Injects a motion event into the application. + * + * @param event the (non-null!) event to inject + * @return true if the event was injected, false otherwise + * @throws InjectEventSecurityException if the event couldn't be injected because it would + * interact with another application. + */ + boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException; + + /** + * Injects a key event into the application. + * + * @param event the (non-null!) event to inject + * @return true if the event was injected, false otherwise + * @throws InjectEventSecurityException if the event couldn't be injected because it would + * interact with another application. + */ + boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException; + + /** + * Types a string into the application using series of {@link KeyEvent}s. It is up to the + * implementor to decide how to map the string to {@link KeyEvent} objects. if you need specific + * control over the key events generated use {@link #injectKeyEvent(KeyEvent)}. + * + * @param str the (non-null!) string to type + * @return true if the string was injected, false otherwise + * @throws InjectEventSecurityException if the events couldn't be injected because it would + * interact with another application. + */ + boolean injectString(String str) throws InjectEventSecurityException; + + /** + * Loops the main thread until the application goes idle. + * + * An empty task is immediately inserted into the task queue to ensure that if we're idle at this + * moment we'll return instantly. + */ + void loopMainThreadUntilIdle(); + + /** + * Loops the main thread for a specified period of time. + * + * Control may not return immediately, instead it'll return after the time has passed and the + * queue is in an idle state again. + * + * @param millisDelay time to spend in looping the main thread + */ + void loopMainThreadForAtLeast(long millisDelay); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java new file mode 100644 index 0000000..12e607e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * Responsible for performing an interaction on the given View element.<br> + * <p> + * This is part of the test framework public API - developers are free to write their own ViewAction + * implementations when necessary. When implementing a new ViewAction, follow these rules: + * <ul> + * <li>Inject motion events or key events via the UiController to simulate user interactions. + * <li>Do not mutate the view directly via setter methods and other state changing methods on the + * view parameter. + * <li>Do not throw AssertionErrors. Assertions belong in ViewAssertion classes. + * <li>View action code will executed on the UI thread, therefore you should not block, perform + * sleeps, or perform other expensive computations. + * <li>The test framework will wait for the UI thread to be idle both before and after perform() is + * called. This means that the action is guaranteed to be synchronized with any other view + * operations. + * <li>Downcasting the View object to an expected subtype is allowed, so long as the object + * expresses the subtype matches the constraints as specified in {@code getConstraints}. + * </ul> + */ +public interface ViewAction { + + /** + * A mechanism for ViewActions to specify what type of views they can operate on. + * + * A ViewAction can demand that the view passed to perform meets certain constraints. For example + * it may want to ensure the view is already in the viewable physical screen of the device or is + * of a certain type. + * + * @return a {@link Matcher} that will be tested prior to calling perform. + */ + public Matcher<View> getConstraints(); + + /** + * Returns a description of the view action. The description should not be overly long and should + * fit nicely in a sentence like: "performing %description% action on view with id ..." + */ + public String getDescription(); + + /** + * Performs this action on the given view. + * + * @param uiController the controller to use to interact with the UI. + * @param view the view to act upon. never null. + */ + public void perform(UiController uiController, View view); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java new file mode 100644 index 0000000..9329b57 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +import javax.annotation.Nullable; + +/** + * Responsible for performing assertions on a View element.<br> + * <p> + * This is considered part of the test framework public API - developers are free to write their own + * assertions as long as they meet the following requirements: + * <ul> + * <li>Do not mutate the passed in view. + * <li>Throw junit.framework.AssertionError when the view assertion does not hold. + * <li>Implementation runs on the UI thread - so it should not do any blocking operations + * <li>Downcasting the view to a specific type is allowed, provided there is a test that view is an + * instance of that type before downcasting. If not, an AssertionError should be thrown. + * <li>It is encouraged to access non-mutating methods on the view to perform assertion. + * </ul> + * <br> + * <p> + * Strongly consider using a existing ViewAssertion via the ViewAssertions utility class before + * writing your own assertion. + */ +public interface ViewAssertion { + + /** + * Checks the state of the given view (if such a view is present). + * + * @param view the view, if one was found during the view interaction or null if it was not + * (which may be an acceptable option for an assertion) + * @param noViewFoundException an exception detailing why the view could not be found or null if + * the view was found + */ + void check(@Nullable View view, @Nullable NoMatchingViewException noViewFoundException); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java new file mode 100644 index 0000000..31e0d11 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import android.view.View; + +/** + * Uses matchers to locate particular views within the view hierarchy. + */ +public interface ViewFinder { + + /** + * Immediately locates a single view within the provided view hierarchy. + * + * If multiple views match, or if no views match the appropriate exception is thrown. + * + * @return A singular view which matches the matcher we were constructed with. + * @throws AmbiguousViewMatcherException when multiple views match + * @throws NoMatchingViewException when no views match. + */ + public View getView() throws AmbiguousViewMatcherException, NoMatchingViewException; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java new file mode 100644 index 0000000..5207083 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java @@ -0,0 +1,178 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.action.ScrollToAction; +import com.google.android.apps.common.testing.ui.espresso.base.MainThread; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.util.Log; +import android.view.View; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; + +/** + * Provides the primary interface for test authors to perform actions or asserts on views. + * <p> + * Each interaction is associated with a view identified by a view matcher. All view actions and + * asserts are performed on the UI thread (thus ensuring sequential execution). The same goes for + * retrieval of views (this is done to ensure that view state is "fresh" prior to execution of each + * operation). + * <p> + */ +public final class ViewInteraction { + + private static final String TAG = ViewInteraction.class.getSimpleName(); + + private final UiController uiController; + private final ViewFinder viewFinder; + private final Executor mainThreadExecutor; + private final FailureHandler failureHandler; + private final Matcher<View> viewMatcher; + private final AtomicReference<Matcher<Root>> rootMatcherRef; + + @Inject + ViewInteraction( + UiController uiController, + ViewFinder viewFinder, + @MainThread Executor mainThreadExecutor, + FailureHandler failureHandler, + Matcher<View> viewMatcher, + AtomicReference<Matcher<Root>> rootMatcherRef) { + this.viewFinder = checkNotNull(viewFinder); + this.uiController = checkNotNull(uiController); + this.failureHandler = checkNotNull(failureHandler); + this.mainThreadExecutor = checkNotNull(mainThreadExecutor); + this.viewMatcher = checkNotNull(viewMatcher); + this.rootMatcherRef = checkNotNull(rootMatcherRef); + } + + /** + * Performs the given action(s) on the view selected by the current view matcher. If more than one + * action is provided, actions are executed in the order provided with precondition checks running + * prior to each action. + * + * @param viewActions one or more actions to execute. + * @return this interaction for further perform/verification calls. + */ + public ViewInteraction perform(final ViewAction... viewActions) { + checkNotNull(viewActions); + for (ViewAction action : viewActions) { + doPerform(action); + } + return this; + } + + + /** + * Makes this ViewInteraction scoped to the root selected by the given root matcher. + */ + public ViewInteraction inRoot(Matcher<Root> rootMatcher) { + this.rootMatcherRef.set(checkNotNull(rootMatcher)); + return this; + } + + private void doPerform(final ViewAction viewAction) { + checkNotNull(viewAction); + final Matcher<? extends View> constraints = checkNotNull(viewAction.getConstraints()); + runSynchronouslyOnUiThread(new Runnable() { + + @Override + public void run() { + uiController.loopMainThreadUntilIdle(); + View targetView = viewFinder.getView(); + Log.i(TAG, String.format( + "Performing '%s' action on view %s", viewAction.getDescription(), viewMatcher)); + if (!constraints.matches(targetView)) { + // TODO(valeraz): update this to describeMismatch once hamcrest is updated to new + // version in google3 (we are waiting for version 1.4 to avoid issues with generics) + StringDescription stringDescription = new StringDescription(new StringBuilder( + "Action will not be performed because the target view " + + "does not match one or more of the following constraints:\n")); + constraints.describeTo(stringDescription); + stringDescription.appendText("\nTarget view: ") + .appendValue(HumanReadables.describe(targetView)); + + if (viewAction instanceof ScrollToAction + && isDescendantOfA(isAssignableFrom((AdapterView.class))).matches(targetView)) { + stringDescription.appendText( + "\nFurther Info: ScrollToAction on a view inside an AdapterView will not work. " + + "Use Espresso.onData to load the view."); + } + throw new PerformException.Builder() + .withActionDescription(viewAction.getDescription()) + .withViewDescription(viewMatcher.toString()) + .withCause(new RuntimeException(stringDescription.toString())) + .build(); + } else { + viewAction.perform(uiController, targetView); + } + } + }); + } + + /** + * Checks the given {@link ViewAssertion} on the the view selected by the current view matcher. + * + * @param viewAssert the assertion to perform. + * @return this interaction for further perform/verification calls. + */ + public ViewInteraction check(final ViewAssertion viewAssert) { + checkNotNull(viewAssert); + runSynchronouslyOnUiThread(new Runnable() { + @Override + public void run() { + uiController.loopMainThreadUntilIdle(); + + View targetView = null; + NoMatchingViewException missingViewException = null; + try { + targetView = viewFinder.getView(); + } catch (NoMatchingViewException nsve) { + missingViewException = nsve; + } + viewAssert.check(targetView, missingViewException); + } + }); + return this; + } + + private void runSynchronouslyOnUiThread(Runnable action) { + FutureTask<Void> uiTask = new FutureTask<Void>(action, null); + mainThreadExecutor.execute(uiTask); + try { + uiTask.get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted running UI task", ie); + } catch (ExecutionException ee) { + failureHandler.handle(ee.getCause(), viewMatcher); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java new file mode 100644 index 0000000..30eccc9 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.base.RootViewPicker; +import com.google.android.apps.common.testing.ui.espresso.base.ViewFinderImpl; +import com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers; + +import android.view.View; + +import dagger.Module; +import dagger.Provides; + +import org.hamcrest.Matcher; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Adds the user interaction scope to the Espresso graph. + */ +@Module( + addsTo = GraphHolder.EspressoModule.class, + injects = {ViewInteraction.class}) +class ViewInteractionModule { + + private final Matcher<View> viewMatcher; + private final AtomicReference<Matcher<Root>> rootMatcher = + new AtomicReference<Matcher<Root>>(RootMatchers.DEFAULT); + + ViewInteractionModule(Matcher<View> viewMatcher) { + this.viewMatcher = checkNotNull(viewMatcher); + } + + @Provides + AtomicReference<Matcher<Root>> provideRootMatcher() { + return rootMatcher; + } + + @Provides + Matcher<View> provideViewMatcher() { + return viewMatcher; + } + + @Provides + ViewFinder provideViewFinder(ViewFinderImpl impl) { + return impl; + } + + @Provides + public View provideRootView(RootViewPicker rootViewPicker) { + // RootsOracle acts as a provider, but returning Providers is illegal, so delegate. + return rootViewPicker.get(); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java new file mode 100644 index 0000000..d682f6b --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java @@ -0,0 +1,156 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.List; + +/** + * Forces an AdapterView to ensure that the data matching a provided data matcher + * is loaded into the current view hierarchy. + * + */ +public final class AdapterDataLoaderAction implements ViewAction { + private final Matcher<Object> dataToLoadMatcher; + private final AdapterViewProtocol adapterViewProtocol; + private final Optional<Integer> atPosition; + private AdapterViewProtocol.AdaptedData adaptedData; + private boolean performed = false; + private Object dataLock = new Object(); + + public AdapterDataLoaderAction(Matcher<Object> dataToLoadMatcher, Optional<Integer> atPosition, + AdapterViewProtocol adapterViewProtocol) { + this.dataToLoadMatcher = checkNotNull(dataToLoadMatcher); + this.atPosition = checkNotNull(atPosition); + this.adapterViewProtocol = checkNotNull(adapterViewProtocol); + } + + public AdapterViewProtocol.AdaptedData getAdaptedData() { + synchronized (dataLock) { + checkState(performed, "perform hasn't been called yet!"); + return adaptedData; + } + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(isAssignableFrom(AdapterView.class), isDisplayed()); + } + + @SuppressWarnings("unchecked") + @Override + public void perform(UiController uiController, View view) { + AdapterView<? extends Adapter> adapterView = (AdapterView<? extends Adapter>) view; + List<AdapterViewProtocol.AdaptedData> matchedDataItems = Lists.newArrayList(); + + for (AdapterViewProtocol.AdaptedData data : adapterViewProtocol.getDataInAdapterView( + adapterView)) { + + if (dataToLoadMatcher.matches(data.data)) { + matchedDataItems.add(data); + } + } + + if (matchedDataItems.size() == 0) { + StringDescription dataMatcherDescription = new StringDescription(); + dataToLoadMatcher.describeTo(dataMatcherDescription); + + if (matchedDataItems.isEmpty()) { + dataMatcherDescription.appendText(" contained values: "); + dataMatcherDescription.appendValue( + adapterViewProtocol.getDataInAdapterView(adapterView)); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("No data found matching: " + dataMatcherDescription)) + .build(); + } + } + + synchronized (dataLock) { + checkState(!performed, "perform called 2x!"); + performed = true; + if (atPosition.isPresent()) { + int matchedDataItemsSize = matchedDataItems.size() - 1; + if (atPosition.get() > matchedDataItemsSize) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "There are only %d elements that matched but requested %d element.", + matchedDataItemsSize, atPosition.get()))) + .build(); + } else { + adaptedData = matchedDataItems.get(atPosition.get()); + } + } else { + if (matchedDataItems.size() != 1) { + StringDescription dataMatcherDescription = new StringDescription(); + dataToLoadMatcher.describeTo(dataMatcherDescription); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Multiple data elements " + + "matched: " + dataMatcherDescription + ". Elements: " + matchedDataItems)) + .build(); + } else { + adaptedData = matchedDataItems.get(0); + } + } + } + + int requestCount = 0; + while (!adapterViewProtocol.isDataRenderedWithinAdapterView(adapterView, adaptedData)) { + if (requestCount > 1) { + if ((requestCount % 50) == 0) { + // sometimes an adapter view will receive an event that will block its attempts to scroll. + adapterView.invalidate(); + adapterViewProtocol.makeDataRenderedWithinAdapterView(adapterView, adaptedData); + } + } else { + adapterViewProtocol.makeDataRenderedWithinAdapterView(adapterView, adaptedData); + } + uiController.loopMainThreadForAtLeast(100); + requestCount++; + } + } + + @Override + public String getDescription() { + return "load adapter data"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java new file mode 100644 index 0000000..c85a76d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; +import com.google.common.base.Optional; + +import android.view.View; +import android.widget.Adapter; +import android.widget.AdapterView; + +import javax.annotation.Nullable; + +/** + * A sadly necessary layer of indirection to interact with AdapterViews. + * <p> + * Generally any subclass should respect the contracts and behaviors of its superclass. Otherwise + * it becomes impossible to work generically with objects that all claim to share a supertype - you + * need special cases to perform the same operation 'owned' by the supertype for each sub-type. The + * 'is - a' relationship is broken. + * </p> + * + * <p> + * Android breaks the Liskov substitution principal with ExpandableListView - you can't use + * getAdapter(), getItemAtPosition(), and other methods common to AdapterViews on an + * ExpandableListView because an ExpandableListView isn't an adapterView - they just share a lot of + * code. + * </p> + * + * <p> + * This interface exists to work around this wart (which sadly is copied in other projects too) and + * lets the implementor translate Espresso's needs and manipulations of the AdapterView into calls + * that make sense for the given subtype and context. + * </p> + * + * <p><i> + * If you have to implement this to talk to widgets your own project defines - I'm sorry. + * </i><p> + * + */ +public interface AdapterViewProtocol { + + /** + * Returns all data this AdapterViewProtocol can find within the given AdapterView. + * + * <p> + * Any AdaptedData returned by this method can be passed to makeDataRenderedWithinView and the + * implementation should make the AdapterView bring that data item onto the screen. + * </p> + * + * @param adapterView the AdapterView we want to interrogate the contents of. + * @return an {@link Iterable} of AdaptedDatas representing all data the implementation sees in + * this view + * @throws IllegalArgumentException if the implementation doesn't know how to manipulate the given + * adapter view. + */ + Iterable<AdaptedData> getDataInAdapterView(AdapterView<? extends Adapter> adapterView); + + /** + * Returns the data object this particular view is rendering if possible. + * + * <p> + * Implementations are expected to create a relationship between the data in the AdapterView and + * the descendant views of the AdapterView that obeys the following conditions: + * </p> + * + * <ul> + * <li>For each descendant view there exists either 0 or 1 data objects it is rendering.</li> + * <li>For each data object the AdapterView there exists either 0 or 1 descendant views which + * claim to be rendering it.</li> + * </ul> + * + * <p> For example - if a PersonObject is rendered into: </p> + * <code> + * LinearLayout + * ImageView picture + * TextView firstName + * TextView lastName + * </code> + * + * <p> + * It would be expected that getDataRenderedByView(adapter, LinearLayout) would return the + * PersonObject. If it were called instead with the TextView or ImageView it would return + * Object.absent(). + * </p> + * + * @param adapterView the adapterview hosting the data. + * @param descendantView a view which is a child, grand-child, or deeper descendant of adapterView + * @return an optional data object the descendant view is rendering. + * @throws IllegalArgumentException if this protocol cannot interrogate this class of adapterView + */ + Optional<AdaptedData> getDataRenderedByView( + AdapterView<? extends Adapter> adapterView, View descendantView); + + /** + * Requests that a particular piece of data held in this AdapterView is actually rendered by it. + * + * <p> + * After calling this method it expected that there will exist some descendant view of adapterView + * for which calling getDataRenderedByView(adapterView, descView).get() == data.data is true. + * <p> + * + * </p> + * Note: this need not happen immediately. EG: an implementor handling ListView may call + * listView.smoothScrollToPosition(data.opaqueToken) - which kicks off an animated scroll over + * the list to the given position. The animation may be in progress after this call returns. The + * only guarantee is that eventually - with no further interaction necessary - this data item + * will be rendered as a child or deeper descendant of this AdapterView. + * </p> + * + * @param adapterView the adapterView hosting the data. + * @param data an AdaptedData instance retrieved by a prior call to getDataInAdapterView + * @throws IllegalArgumentException if this protocol cannot manipulate adapterView or if data is + * not owned by this AdapterViewProtocol. + */ + void makeDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData data); + + + /** + * Indicates whether or not there now exists a descendant view within adapterView that + * is rendering this data. + * + * @param adapterView the AdapterView hosting this data. + * @param adaptedData the data we are checking the display state for. + * @return true if the data is rendered by a view in the adapterView, false otherwise. + */ + boolean isDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData adaptedData); + + + /** + * A holder that associates a data object from an AdapterView with a token the + * AdapterViewProtocol can use to force that data object to be rendered as a child or deeper + * descendant of the adapter view. + */ + public static class AdaptedData { + + /** + * One of the objects the AdapterView is exposing to the user. + */ + @Nullable + public final Object data; + + /** + * A token the implementor of AdapterViewProtocol can use to force the adapterView to display + * this data object as a child or deeper descendant in it. Equal opaqueToken point to the same + * data object on the AdapterView. + */ + public final Object opaqueToken; + + @Override + public String toString() { + return String.format("Data: %s (class: %s) token: %s", data, + null == data ? null : data.getClass(), opaqueToken); + } + + private AdaptedData(Object data, Object opaqueToken) { + this.data = data; + this.opaqueToken = checkNotNull(opaqueToken); + } + + public static class Builder { + private Object data; + private Object opaqueToken; + + public Builder withData(@Nullable Object data) { + this.data = data; + return this; + } + + public Builder withOpaqueToken(@Nullable Object opaqueToken) { + this.opaqueToken = opaqueToken; + return this; + } + + public AdaptedData build() { + return new AdaptedData(data, opaqueToken); + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java new file mode 100644 index 0000000..5fc6032 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java @@ -0,0 +1,158 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Optional; +import com.google.common.collect.Lists; +import com.google.common.collect.Range; + +import android.os.Build; +import android.view.View; +import android.widget.AbsListView; +import android.widget.Adapter; +import android.widget.AdapterView; +import android.widget.AdapterViewAnimator; +import android.widget.AdapterViewFlipper; + +import java.util.List; + +/** + * Implementations of {@link AdapterViewProtocol} for standard SDK Widgets. + * + */ +public final class AdapterViewProtocols { + + /** + * Consider views which have over this percentage of their area visible to the user + * to be fully rendered. + */ + private static final int FULLY_RENDERED_PERCENTAGE_CUTOFF = 90; + + private AdapterViewProtocols() {} + + private static final AdapterViewProtocol STANDARD_PROTOCOL = new StandardAdapterViewProtocol(); + + /** + * Creates an implementation of AdapterViewProtocol that can work with AdapterViews that do not + * break method contracts on AdapterView. + * + */ + public static AdapterViewProtocol standardProtocol() { + return STANDARD_PROTOCOL; + } + + // TODO(user): expandablelistview protocols + + private static final class StandardAdapterViewProtocol implements AdapterViewProtocol { + @Override + public Iterable<AdaptedData> getDataInAdapterView(AdapterView<? extends Adapter> adapterView) { + List<AdaptedData> datas = Lists.newArrayList(); + for (int i = 0; i < adapterView.getCount(); i++) { + datas.add( + new AdaptedData.Builder() + .withData(adapterView.getItemAtPosition(i)) + .withOpaqueToken(i) + .build()); + } + return datas; + } + + @Override + public Optional<AdaptedData> getDataRenderedByView(AdapterView<? extends Adapter> adapterView, + View descendantView) { + if (adapterView == descendantView.getParent()) { + int position = adapterView.getPositionForView(descendantView); + if (position != AdapterView.INVALID_POSITION) { + return Optional.of(new AdaptedData.Builder() + .withData(adapterView.getItemAtPosition(position)) + .withOpaqueToken(Integer.valueOf(position)) + .build()); + } + } + return Optional.absent(); + } + + @Override + public void makeDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData data) { + checkArgument(data.opaqueToken instanceof Integer, "Not my data: %s", data); + int position = ((Integer) data.opaqueToken).intValue(); + + boolean moved = false; + // set selection should always work, we can give a little better experience if per subtype + // though. + if (Build.VERSION.SDK_INT > 7) { + if (adapterView instanceof AbsListView) { + if (Build.VERSION.SDK_INT > 10) { + ((AbsListView) adapterView).smoothScrollToPositionFromTop(position, + adapterView.getPaddingTop(), 0); + } else { + ((AbsListView) adapterView).smoothScrollToPosition(position); + } + moved = true; + } + if (Build.VERSION.SDK_INT > 10) { + if (adapterView instanceof AdapterViewAnimator) { + if (adapterView instanceof AdapterViewFlipper) { + ((AdapterViewFlipper) adapterView).stopFlipping(); + } + ((AdapterViewAnimator) adapterView).setDisplayedChild(position); + moved = true; + } + } + } + if (!moved) { + adapterView.setSelection(position); + } + } + + @SuppressWarnings("deprecation") + @Override + public boolean isDataRenderedWithinAdapterView( + AdapterView<? extends Adapter> adapterView, AdaptedData adaptedData) { + checkArgument(adaptedData.opaqueToken instanceof Integer, "Not my data: %s", adaptedData); + int dataPosition = ((Integer) adaptedData.opaqueToken).intValue(); + + if (Range.closed(adapterView.getFirstVisiblePosition(), adapterView.getLastVisiblePosition()) + .contains(dataPosition)) { + if (adapterView.getFirstVisiblePosition() == adapterView.getLastVisiblePosition()) { + // thats a huge element. + return true; + } else { + return isElementFullyRendered(adapterView, + dataPosition - adapterView.getFirstVisiblePosition()); + } + } else { + return false; + } + } + + private boolean isElementFullyRendered(AdapterView<? extends Adapter> adapterView, + int childAt) { + View element = adapterView.getChildAt(childAt); + // Occassionally we'll have to fight with smooth scrolling logic on our definition of when + // there is extra scrolling to be done. In particular if the element is the first or last + // element of the list, the smooth scroller may decide that no work needs to be done to scroll + // to the element if a certain percentage of it is on screen. Ugh. Sigh. Yuck. + + return isDisplayingAtLeast(FULLY_RENDERED_PERCENTAGE_CUTOFF).matches(element); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java new file mode 100644 index 0000000..e3d997a --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; + +import android.view.View; +import android.widget.EditText; + +import org.hamcrest.Matcher; + +/** + * Clears view text by setting {@link EditText}s text property to "". + */ +public final class ClearTextAction implements ViewAction { + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(isDisplayed(), isAssignableFrom(EditText.class)); + } + + @Override + public void perform(UiController uiController, View view) { + ((EditText) view).setText(""); + } + + @Override + public String getDescription() { + return "clear text"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java new file mode 100644 index 0000000..6026a68 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java @@ -0,0 +1,140 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.collect.Iterables.getOnlyElement; +import static org.hamcrest.Matchers.anything; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.os.ResultReceiver; +import android.util.Log; +import android.view.View; +import android.view.inputmethod.InputMethodManager; + +import org.hamcrest.Matcher; + +import java.util.Collection; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Closes soft keyboard. + */ +public final class CloseKeyboardAction implements ViewAction { + + private static final int NUM_RETRIES = 3; + private static final String TAG = CloseKeyboardAction.class.getSimpleName(); + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return anything(); + } + + @Override + public void perform(UiController uiController, View view) { + // Retry in case of timeout exception to avoid flakiness in IMM. + for (int i = 0; i < NUM_RETRIES; i++) { + try { + tryToCloseKeyboard(view, uiController); + return; + } catch (TimeoutException te) { + Log.w(TAG, "Caught timeout exception. Retrying."); + if (i == 2) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(te) + .build(); + } + } + } + } + + private void tryToCloseKeyboard(View view, UiController uiController) throws TimeoutException { + InputMethodManager imm = (InputMethodManager) getRootActivity(uiController) + .getSystemService(Context.INPUT_METHOD_SERVICE); + final AtomicInteger atomicResultCode = new AtomicInteger(); + final CountDownLatch latch = new CountDownLatch(1); + + ResultReceiver result = new ResultReceiver(null) { + @Override + protected void onReceiveResult(int resultCode, Bundle resultData) { + atomicResultCode.set(resultCode); + latch.countDown(); + } + }; + + if (!imm.hideSoftInputFromWindow(view.getWindowToken(), 0, result)) { + Log.w(TAG, "Attempting to close soft keyboard, while it is not shown."); + return; + } + + try { + if (!latch.await(2, TimeUnit.SECONDS)) { + throw new TimeoutException("Wait on operation result timed out."); + } + } catch (InterruptedException e) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Waiting for soft keyboard close result was interrupted.")) + .build(); + } + + if (atomicResultCode.get() != InputMethodManager.RESULT_UNCHANGED_HIDDEN + && atomicResultCode.get() != InputMethodManager.RESULT_HIDDEN) { + String error = + "Attempt to close the soft keyboard did not result in soft keyboard to be hidden." + + "resultCode = " + atomicResultCode.get(); + Log.e(TAG, error); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(error)) + .build(); + } + } + + private static Activity getRootActivity(UiController uiController) { + Collection<Activity> resumedActivities = + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + uiController.loopMainThreadUntilIdle(); + resumedActivities = + ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED); + } + Activity topActivity = getOnlyElement(resumedActivities); + return topActivity; + } + + @Override + public String getDescription() { + return "close keyboard"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java new file mode 100644 index 0000000..c8c9823 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import android.view.View; + +/** + * Interface to implement calculation of Coordinates. + */ +public interface CoordinatesProvider { + + /** + * Calculates coordinates of given view. + * + * @param view the View which is used for the calculation. + * @return a float[] with x and y values of the calculated coordinates. + */ + public float[] calculateCoordinates(View view); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java new file mode 100644 index 0000000..6b78cb9 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +import org.hamcrest.Matcher; + +/** + * Performs whatever editor (IME) action is available on a view. + */ +public final class EditorAction implements ViewAction { + + @Override + public Matcher<View> getConstraints() { + return isDisplayed(); + } + + @Override + public String getDescription() { + return "input method editor"; + } + + @Override + public void perform(UiController uiController, View view) { + EditorInfo editorInfo = new EditorInfo(); + InputConnection inputConnection = view.onCreateInputConnection(editorInfo); + if (inputConnection == null) { + throw new PerformException.Builder() + .withActionDescription(this.toString()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new IllegalStateException("View does not support input methods")) + .build(); + } + + int actionId = editorInfo.actionId != 0 ? editorInfo.actionId : + editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + + if (actionId == EditorInfo.IME_ACTION_NONE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new IllegalStateException("No available action on view")) + .build(); + } + + if (!inputConnection.performEditorAction(actionId)) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "Failed to perform action %#x. Input connection no longer valid", actionId))) + .build(); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java new file mode 100644 index 0000000..530ddde --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkState; + +import android.os.Build; +import android.view.KeyEvent; + +/** + * Class that wraps the key code and meta state of the desired key press. + */ +public final class EspressoKey { + private final int keyCode; + private final int metaState; + + private EspressoKey(Builder builder) { + this.keyCode = builder.builderKeyCode; + this.metaState = builder.getMetaState(); + } + + public int getKeyCode() { + return keyCode; + } + + public int getMetaState() { + return metaState; + } + + @Override + public String toString() { + return String.format("keyCode: %s, metaState: %s", keyCode, metaState); + } + + /** + * Builder for the EspressoKey class. + */ + public static class Builder { + private int builderKeyCode = -1; + private boolean isShiftPressed; + private boolean isAltPressed; + private boolean isCtrlPressed; + + public Builder withKeyCode(int keyCode) { + builderKeyCode = keyCode; + return this; + } + + /** + * Sets the SHIFT_ON meta state of the resulting key. + */ + public Builder withShiftPressed(boolean shiftPressed) { + isShiftPressed = shiftPressed; + return this; + } + + /** + * On Honeycomb and above, sets the CTRL_ON meta state of the resulting key. On Gingerbread and + * below, this is a noop. + */ + public Builder withCtrlPressed(boolean ctrlPressed) { + isCtrlPressed = ctrlPressed; + return this; + } + + /** + * Sets the ALT_ON meta state of the resulting key. + */ + public Builder withAltPressed(boolean altPressed) { + isAltPressed = altPressed; + return this; + } + + private int getMetaState() { + int metaState = 0; + if (isShiftPressed) { + metaState |= KeyEvent.META_SHIFT_ON; + } + + if (isAltPressed) { + metaState |= KeyEvent.META_ALT_ON; + } + + if (isCtrlPressed && Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) { + metaState |= KeyEvent.META_CTRL_ON; + } + + return metaState; + } + + public EspressoKey build() { + checkState(builderKeyCode > 0 && builderKeyCode < KeyEvent.getMaxKeyCode(), + "Invalid key code: %s", builderKeyCode); + return new EspressoKey(this); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java new file mode 100644 index 0000000..857501d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static org.hamcrest.Matchers.allOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Optional; + +import android.view.View; +import android.view.ViewConfiguration; +import android.webkit.WebView; + +import org.hamcrest.Matcher; + +/** + * Enables clicking on views. + */ +public final class GeneralClickAction implements ViewAction { + + private final CoordinatesProvider coordinatesProvider; + private final Tapper tapper; + private final PrecisionDescriber precisionDescriber; + private final Optional<ViewAction> rollbackAction; + + public GeneralClickAction(Tapper tapper, CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber) { + this(tapper, coordinatesProvider, precisionDescriber, null); + } + + public GeneralClickAction(Tapper tapper, CoordinatesProvider coordinatesProvider, + PrecisionDescriber precisionDescriber, ViewAction rollbackAction) { + this.coordinatesProvider = coordinatesProvider; + this.tapper = tapper; + this.precisionDescriber = precisionDescriber; + this.rollbackAction = Optional.fromNullable(rollbackAction); + } + + @Override + @SuppressWarnings("unchecked") + public Matcher<View> getConstraints() { + Matcher<View> standardConstraint = isDisplayingAtLeast(90); + if (rollbackAction.isPresent()) { + return allOf(standardConstraint, rollbackAction.get().getConstraints()); + } else { + return standardConstraint; + } + } + + @Override + public void perform(UiController uiController, View view) { + float[] coordinates = coordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Tapper.Status status = Tapper.Status.FAILURE; + int loopCount = 0; + // Native event injection is quite a tricky process. A tap is actually 2 + // seperate motion events which need to get injected into the system. Injection + // makes an RPC call from our app under test to the Android system server, the + // system server decides which window layer to deliver the event to, the system + // server makes an RPC to that window layer, that window layer delivers the event + // to the correct UI element, activity, or window object. Now we need to repeat + // that 2x. for a simple down and up. Oh and the down event triggers timers to + // detect whether or not the event is a long vs. short press. The timers are + // removed the moment the up event is received (NOTE: the possibility of eventTime + // being in the future is totally ignored by most motion event processors). + // + // Phew. + // + // The net result of this is sometimes we'll want to do a regular tap, and for + // whatever reason the up event (last half) of the tap is delivered after long + // press timeout (depending on system load) and the long press behaviour is + // displayed (EG: show a context menu). There is no way to avoid or handle this more + // gracefully. Also the longpress behavour is app/widget specific. So if you have + // a seperate long press behaviour from your short press, you can pass in a + // 'RollBack' ViewAction which when executed will undo the effects of long press. + + while (status != Tapper.Status.SUCCESS && loopCount < 3) { + try { + status = tapper.sendTap(uiController, coordinates, precision); + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + // ensures that all work enqueued to process the tap has been run. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration()); + if (status == Tapper.Status.WARNING) { + if (rollbackAction.isPresent()) { + rollbackAction.get().perform(uiController, view); + } else { + break; + } + } + loopCount++; + } + if (status == Tapper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format("Couldn't " + + "click at: %s,%s precision: %s, %s . Tapper: %s coordinate provider: %s precision " + + "describer: %s. Tried %s times. With Rollback? %s", coordinates[0], coordinates[1], + precision[0], precision[1], tapper, coordinatesProvider, precisionDescriber, loopCount, + rollbackAction.isPresent()))) + .build(); + } + + if (tapper == Tap.SINGLE && view instanceof WebView) { + // WebViews will not process click events until double tap + // timeout. Not the best place for this - but good for now. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getDoubleTapTimeout()); + } + } + + @Override + public String getDescription() { + return tapper.toString().toLowerCase() + " click"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java new file mode 100644 index 0000000..f74775e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java @@ -0,0 +1,112 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import android.view.View; + +/** + * Calculates coordinate position for general locations. + */ +public enum GeneralLocation implements CoordinatesProvider { + + TOP_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.BEGIN); + } + }, + TOP_CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.MIDDLE); + } + }, + TOP_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.BEGIN, Position.END); + } + }, + CENTER_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.BEGIN); + } + }, + CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.MIDDLE); + } + }, + CENTER_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.MIDDLE, Position.END); + } + }, + BOTTOM_LEFT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.BEGIN); + } + }, + BOTTOM_CENTER { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.MIDDLE); + } + }, + BOTTOM_RIGHT { + @Override + public float[] calculateCoordinates(View view) { + return getCoordinates(view, Position.END, Position.END); + } + }; + + private static float[] getCoordinates(View view, Position vertical, Position horizontal) { + final int[] xy = new int[2]; + view.getLocationOnScreen(xy); + final float x = horizontal.getPosition(xy[0], view.getWidth()); + final float y = vertical.getPosition(xy[1], view.getHeight()); + float[] coordinates = {x, y}; + return coordinates; + } + + private static enum Position { + BEGIN { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos; + } + }, + MIDDLE { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos + (viewLength / 2.0f); + } + }, + END { + @Override + public float getPosition(int viewPos, int viewLength) { + return viewPos + viewLength; + } + }; + + abstract float getPosition(int widgetPos, int widgetLength); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java new file mode 100644 index 0000000..6482250 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.view.View; +import android.view.ViewConfiguration; + +import org.hamcrest.Matcher; + +/** + * Enables swiping across a view. + */ +public final class GeneralSwipeAction implements ViewAction { + + /** Maximum number of times to attempt sending a swipe action. */ + private static final int MAX_TRIES = 3; + + /** The minimum amount of a view that must be displayed in order to swipe across it. */ + private static final int VIEW_DISPLAY_PERCENTAGE = 90; + + private final CoordinatesProvider startCoordinatesProvider; + private final CoordinatesProvider endCoordinatesProvider; + private final Swiper swiper; + private final PrecisionDescriber precisionDescriber; + + public GeneralSwipeAction(Swiper swiper, CoordinatesProvider startCoordinatesProvider, + CoordinatesProvider endCoordinatesProvider, PrecisionDescriber precisionDescriber) { + this.swiper = swiper; + this.startCoordinatesProvider = startCoordinatesProvider; + this.endCoordinatesProvider = endCoordinatesProvider; + this.precisionDescriber = precisionDescriber; + } + + @Override + public Matcher<View> getConstraints() { + return isDisplayingAtLeast(VIEW_DISPLAY_PERCENTAGE); + } + + @Override + public void perform(UiController uiController, View view) { + float[] startCoordinates = startCoordinatesProvider.calculateCoordinates(view); + float[] endCoordinates = endCoordinatesProvider.calculateCoordinates(view); + float[] precision = precisionDescriber.describePrecision(); + + Swiper.Status status = Swiper.Status.FAILURE; + + for (int tries = 0; tries < MAX_TRIES && status != Swiper.Status.SUCCESS; tries++) { + try { + status = swiper.sendSwipe(uiController, startCoordinates, endCoordinates, precision); + } catch (RuntimeException re) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(re) + .build(); + } + + // ensures that all work enqueued to process the swipe has been run. + uiController.loopMainThreadForAtLeast(ViewConfiguration.getPressedStateDuration()); + } + + if (status == Swiper.Status.FAILURE) { + throw new PerformException.Builder() + .withActionDescription(getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException(String.format( + "Couldn't swipe from: %s,%s to: %s,%s precision: %s, %s . Swiper: %s " + + "start coordinate provider: %s precision describer: %s. Tried %s times", + startCoordinates[0], + startCoordinates[1], + endCoordinates[0], + endCoordinates[1], + precision[0], + precision[1], + swiper, + startCoordinatesProvider, + precisionDescriber, + MAX_TRIES))) + .build(); + } + } + + @Override + public String getDescription() { + return swiper.toString().toLowerCase() + " swipe"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java new file mode 100644 index 0000000..1be85f7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java @@ -0,0 +1,125 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; + +import org.hamcrest.Matcher; + +/** + * Enables pressing KeyEvents on views. + */ +public final class KeyEventAction implements ViewAction { + private static final String TAG = KeyEventAction.class.getSimpleName(); + + private final EspressoKey key; + + public KeyEventAction(EspressoKey key) { + this.key = checkNotNull(key); + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return isDisplayed(); + } + + @Override + public void perform(UiController uiController, View view) { + try { + if (!sendKeyEvent(uiController, view)) { + Log.e(TAG, "Failed to inject key event: " + this.key); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Failed to inject key event " + this.key)) + .build(); + } + } catch (InjectEventSecurityException e) { + Log.e(TAG, "Failed to inject key event: " + this.key); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(e) + .build(); + } + } + + private final boolean sendKeyEvent(UiController controller, View view) + throws InjectEventSecurityException { + + boolean injected = false; + long eventTime = SystemClock.uptimeMillis(); + for (int attempts = 0; !injected && attempts < 4; attempts++) { + injected = controller.injectKeyEvent(new KeyEvent(eventTime, + eventTime, + KeyEvent.ACTION_DOWN, + this.key.getKeyCode(), + 0, + this.key.getMetaState())); + } + + if (!injected) { + // it is not a transient failure... :( + return false; + } + + injected = false; + eventTime = SystemClock.uptimeMillis(); + for (int attempts = 0; !injected && attempts < 4; attempts++) { + injected = controller.injectKeyEvent( + new KeyEvent(eventTime, eventTime, KeyEvent.ACTION_UP, this.key.getKeyCode(), 0)); + } + + + if (this.key.getKeyCode() == KeyEvent.KEYCODE_BACK) { + controller.loopMainThreadUntilIdle(); + boolean activeActivities = !ActivityLifecycleMonitorRegistry.getInstance() + .getActivitiesInStage(Stage.RESUMED) + .isEmpty(); + if (!activeActivities) { + Throwable cause = new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .build(); + throw new NoActivityResumedException("Pressed back and killed the app", cause); + } + } + + return injected; + } + + @Override + public String getDescription() { + return String.format("send %s key event", this.key); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java new file mode 100644 index 0000000..1f38339 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java @@ -0,0 +1,249 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.testrunner.UsageTrackerRegistry; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.annotations.VisibleForTesting; + +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * Facilitates sending of motion events to a {@link UiController}. + */ +final class MotionEvents { + + private static final String TAG = MotionEvents.class.getSimpleName(); + + @VisibleForTesting + static final int MAX_CLICK_ATTEMPTS = 3; + + private MotionEvents() { + // Shouldn't be instantiated + } + + static DownResultHolder sendDown( + UiController uiController, float[] coordinates, float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + + for (int retry = 0; retry < MAX_CLICK_ATTEMPTS; retry++) { + MotionEvent motionEvent = null; + try { + // Algorithm of sending click event adopted from android.test.TouchUtils. + // When the click event was first initiated. Needs to be same for both down and up press + // events. + long downTime = SystemClock.uptimeMillis(); + + // Down press. + motionEvent = MotionEvent.obtain(downTime, + SystemClock.uptimeMillis(), + MotionEvent.ACTION_DOWN, + coordinates[0], + coordinates[1], + 0, // pressure + 1, // size + 0, // metaState + precision[0], // xPrecision + precision[1], // yPrecision + 0, // deviceId + 0); // edgeFlags + // The down event should be considered a tap if it is long enough to be detected + // but short enough not to be a long-press. Assume that TapTimeout is set at least + // twice the detection time for a tap (no need to sleep for the whole TapTimeout since + // we aren't concerned about scrolling here). + long isTapAt = downTime + (ViewConfiguration.getTapTimeout() / 2); + + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + while (true) { + long delayToBeTap = isTapAt - SystemClock.uptimeMillis(); + if (delayToBeTap <= 10) { + break; + } + // Sleep only a fraction of the time, since there may be other events in the UI queue + // that could cause us to start sleeping late, and then oversleep. + uiController.loopMainThreadForAtLeast(delayToBeTap / 4); + } + + boolean longPress = false; + if (SystemClock.uptimeMillis() > (downTime + ViewConfiguration.getLongPressTimeout())) { + longPress = true; + Log.e(TAG, "Overslept and turned a tap into a long press"); + UsageTrackerRegistry.getInstance().trackUsage("Espresso.Tap.Error.tapToLongPress"); + } + + if (!injectEventSucceeded) { + motionEvent.recycle(); + motionEvent = null; + continue; + } + + return new DownResultHolder(motionEvent, longPress); + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription("Send down montion event") + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } + } + throw new PerformException.Builder() + .withActionDescription(String.format("click (after %s attempts)", MAX_CLICK_ATTEMPTS)) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .build(); + } + + static boolean sendUp(UiController uiController, MotionEvent downEvent) { + return sendUp(uiController, downEvent, new float[] { downEvent.getX(), downEvent.getY() }); + } + + static boolean sendUp(UiController uiController, MotionEvent downEvent, float[] coordinates) { + checkNotNull(uiController); + checkNotNull(downEvent); + checkNotNull(coordinates); + + MotionEvent motionEvent = null; + try { + // Up press. + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + coordinates[0], + coordinates[1], + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + Log.e(TAG, String.format( + "Injection of up event failed (corresponding down event: %s)", downEvent.toString())); + return false; + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription( + String.format("inject up event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + return true; + } + + static void sendCancel(UiController uiController, MotionEvent downEvent) { + checkNotNull(uiController); + checkNotNull(downEvent); + + MotionEvent motionEvent = null; + try { + // Up press. + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_CANCEL, + downEvent.getX(), + downEvent.getY(), + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject cancel event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .build(); + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject cancel event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + } + + static boolean sendMovement(UiController uiController, MotionEvent downEvent, + float[] coordinates) { + checkNotNull(uiController); + checkNotNull(downEvent); + checkNotNull(coordinates); + + MotionEvent motionEvent = null; + try { + motionEvent = MotionEvent.obtain(downEvent.getDownTime(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_MOVE, + coordinates[0], + coordinates[1], + 0); + boolean injectEventSucceeded = uiController.injectMotionEvent(motionEvent); + + if (!injectEventSucceeded) { + Log.e(TAG, String.format( + "Injection of motion event failed (corresponding down event: %s)", + downEvent.toString())); + return false; + } + } catch (InjectEventSecurityException e) { + throw new PerformException.Builder() + .withActionDescription(String.format( + "inject motion event (corresponding down event: %s)", downEvent.toString())) + .withViewDescription("unknown") // likely to be replaced by FailureHandler + .withCause(e) + .build(); + } finally { + if (null != motionEvent) { + motionEvent.recycle(); + motionEvent = null; + } + } + + return true; + } + + /** + * Holds the result of a down motion. + */ + static class DownResultHolder { + public final MotionEvent down; + public final boolean longPress; + + DownResultHolder(MotionEvent down, boolean longPress) { + this.down = down; + this.longPress = longPress; + } + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java new file mode 100644 index 0000000..422de8e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +/** + * Interface to implement size of click area. + */ +public interface PrecisionDescriber { + + /** + * Different touch target sizes. + * + * @return a float[] with x and y values of size of click area. + */ + public float[] describePrecision(); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java new file mode 100644 index 0000000..883c852 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +/** + * Returns different touch target sizes. + */ +public enum Press implements PrecisionDescriber { + PINPOINT { + @Override + public float[] describePrecision() { + float[] pinpoint = {1f, 1f}; + return pinpoint; + } + }, + FINGER { + // average width of the index finger is 16 – 20 mm. + @Override + public float[] describePrecision() { + float finger[] = {16f, 16f}; + return finger; + } + }, + // average width of an adult thumb is 25 mm (1 inch). + THUMB { + @Override + public float[] describePrecision() { + float thumb[] = {25f, 25f}; + return thumb; + } + }; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java new file mode 100644 index 0000000..9d613c3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDescendantOfA; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayingAtLeast; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.Visibility; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.graphics.Rect; +import android.util.Log; +import android.view.View; +import android.widget.HorizontalScrollView; +import android.widget.ScrollView; + +import org.hamcrest.Matcher; + +/** + * Enables scrolling to the given view. View must be a descendant of a ScrollView. + */ +public final class ScrollToAction implements ViewAction { + private static final String TAG = ScrollToAction.class.getSimpleName(); + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + return allOf(withEffectiveVisibility(Visibility.VISIBLE), isDescendantOfA(anyOf( + isAssignableFrom(ScrollView.class), isAssignableFrom(HorizontalScrollView.class)))); + } + + @Override + public void perform(UiController uiController, View view) { + if (isDisplayingAtLeast(90).matches(view)) { + Log.i(TAG, "View is already displayed. Returning."); + return; + } + Rect rect = new Rect(); + view.getDrawingRect(rect); + if (!view.requestRectangleOnScreen(rect, true /* immediate */)) { + Log.w(TAG, "Scrolling to view was requested, but none of the parents scrolled."); + } + uiController.loopMainThreadUntilIdle(); + if (!isDisplayingAtLeast(90).matches(view)) { + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException( + "Scrolling to view was attempted, but the view is not displayed")) + .build(); + } + } + + @Override + public String getDescription() { + return "scroll to"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java new file mode 100644 index 0000000..06e8dc8 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkElementIndex; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +import android.os.SystemClock; +import android.util.Log; +import android.view.MotionEvent; + +/** + * Executes different swipe types to given positions. + */ +public enum Swipe implements Swiper { + + /** Swipes quickly between the co-ordinates. */ + FAST { + @Override + public Swiper.Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision) { + return sendLinearSwipe(uiController, startCoordinates, endCoordinates, precision, + SWIPE_FAST_DURATION_MS); + } + }, + + /** Swipes deliberately slowly between the co-ordinates, to aid in visual debugging. */ + SLOW { + @Override + public Swiper.Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision) { + return sendLinearSwipe(uiController, startCoordinates, endCoordinates, precision, + SWIPE_SLOW_DURATION_MS); + } + }; + + private static final String TAG = Swipe.class.getSimpleName(); + + /** The number of motion events to send for each swipe. */ + private static final int SWIPE_EVENT_COUNT = 10; + + /** Length of time a "fast" swipe should last for, in milliseconds. */ + private static final int SWIPE_FAST_DURATION_MS = 100; + + /** Length of time a "slow" swipe should last for, in milliseconds. */ + private static final int SWIPE_SLOW_DURATION_MS = 1500; + + private static float[][] interpolate(float[] start, float[] end, int steps) { + checkElementIndex(1, start.length); + checkElementIndex(1, end.length); + + float[][] res = new float[steps][2]; + + for (int i = 1; i < steps + 1; i++) { + res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (steps + 2f); + res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (steps + 2f); + } + + return res; + } + + private static Swiper.Status sendLinearSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision, int duration) { + checkNotNull(uiController); + checkNotNull(startCoordinates); + checkNotNull(endCoordinates); + checkNotNull(precision); + + float[][] steps = interpolate(startCoordinates, endCoordinates, SWIPE_EVENT_COUNT); + final int delayBetweenMovements = duration / steps.length; + + MotionEvent downEvent = MotionEvents.sendDown(uiController, steps[0], precision).down; + try { + for (int i = 1; i < steps.length; i++) { + if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) { + Log.e(TAG, "Injection of move event as part of the swipe failed. Sending cancel event."); + MotionEvents.sendCancel(uiController, downEvent); + return Swiper.Status.FAILURE; + } + + long desiredTime = downEvent.getDownTime() + delayBetweenMovements * i; + long timeUntilDesired = desiredTime - SystemClock.uptimeMillis(); + if (timeUntilDesired > 10) { + uiController.loopMainThreadForAtLeast(timeUntilDesired); + } + } + + if (!MotionEvents.sendUp(uiController, downEvent, endCoordinates)) { + Log.e(TAG, "Injection of up event as part of the swipe failed. Sending cancel event."); + MotionEvents.sendCancel(uiController, downEvent); + return Swiper.Status.FAILURE; + } + } finally { + downEvent.recycle(); + } + return Swiper.Status.SUCCESS; + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java new file mode 100644 index 0000000..41ac593 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +import android.view.MotionEvent; + +/** + * Interface to implement different swipe types. + */ +public interface Swiper { + + /** + * The result of the swipe. + */ + public enum Status { + /** + * The swipe action completed successfully. + */ + SUCCESS, + /** + * Injecting the event was a complete failure. + */ + FAILURE + } + + /** + * Swipes from {@code startCoordinates} to {@code endCoordinates} using the given + * {@code uiController} to send {@link MotionEvent}s. + * + * @param uiController a UiController to use to send MotionEvents to the screen. + * @param startCoordinates a float[] with x and y co-ordinates of the start of the swipe. + * @param endCoordinates a float[] with x and y co-ordinates of the end of the swipe. + * @param precision a float[] with x and y values of precision of the tap. + * @return The status of the swipe. + */ + public Status sendSwipe(UiController uiController, float[] startCoordinates, + float[] endCoordinates, float[] precision); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java new file mode 100644 index 0000000..712f8cc --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.action.MotionEvents.DownResultHolder; + +import android.util.Log; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * Executes different click types to given position. + */ +public enum Tap implements Tapper { + SINGLE { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + + checkNotNull(coordinates); + checkNotNull(precision); + DownResultHolder res = MotionEvents.sendDown(uiController, coordinates, precision); + try { + if (!MotionEvents.sendUp(uiController, res.down)) { + Log.d(TAG, "Injection of up event as part of the click failed. Send cancel event."); + MotionEvents.sendCancel(uiController, res.down); + return Tapper.Status.FAILURE; + } + } finally { + res.down.recycle(); + } + return res.longPress ? Tapper.Status.WARNING : Tapper.Status.SUCCESS; + } + }, + LONG { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + + MotionEvent downEvent = MotionEvents.sendDown(uiController, coordinates, precision).down; + try { + // Duration before a press turns into a long press. + // Factor 1.5 is needed, otherwise a long press is not safely detected. + // See android.test.TouchUtils longClickView + long longPressTimeout = (long) (ViewConfiguration.getLongPressTimeout() * 1.5f); + uiController.loopMainThreadForAtLeast(longPressTimeout); + + if (!MotionEvents.sendUp(uiController, downEvent)) { + MotionEvents.sendCancel(uiController, downEvent); + return Tapper.Status.FAILURE; + } + } finally { + downEvent.recycle(); + downEvent = null; + } + return Tapper.Status.SUCCESS; + } + }, + DOUBLE { + @Override + public Tapper.Status sendTap(UiController uiController, float[] coordinates, + float[] precision) { + checkNotNull(uiController); + checkNotNull(coordinates); + checkNotNull(precision); + Tapper.Status stat = SINGLE.sendTap(uiController, coordinates, precision); + if (stat == Tapper.Status.FAILURE) { + return Tapper.Status.FAILURE; + } + + Tapper.Status secondStat = SINGLE.sendTap(uiController, coordinates, precision); + + if (secondStat == Tapper.Status.FAILURE) { + return Tapper.Status.FAILURE; + } + + if (secondStat == Tapper.Status.WARNING || stat == Tapper.Status.WARNING) { + return Tapper.Status.WARNING; + } else { + return Tapper.Status.SUCCESS; + } + } + }; + + private static final String TAG = Tap.class.getSimpleName(); + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java new file mode 100644 index 0000000..8a57c53 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import com.google.android.apps.common.testing.ui.espresso.UiController; + +/** + * Interface to implement different click types. + */ +public interface Tapper { + + /** + * The result of the tap. + */ + public enum Status { + /** + * The Tap action completed successfully. + */ + SUCCESS, + /** + * The action seemed to have completed - but may have been misinterpreted + * by the application. (For Example a TAP became a LONG PRESS by measuring + * its time between the down and up events). + */ + WARNING, + /** + * Injecting the event was a complete failure. + */ + FAILURE } + + /** + * Sends a MotionEvent to the given UiController. + * + * @param uiController a UiController to use to send MotionEvents to the screen. + * @param coordinates a float[] with x and y values of center of the tap. + * @param precision a float[] with x and y values of precision of the tap. + * @return The status of the tap. + */ + public Status sendTap(UiController uiController, float[] coordinates, float[] precision); +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java new file mode 100644 index 0000000..81d388b --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.hasFocus; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isAssignableFrom; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.isDisplayed; +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.supportsInputMethods; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.PerformException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.ViewAction; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; + +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.widget.SearchView; + +import org.hamcrest.Matcher; + +/** + * Enables typing text on views. + */ +public final class TypeTextAction implements ViewAction { + private static final String TAG = TypeTextAction.class.getSimpleName(); + private final String stringToBeTyped; + private final boolean tapToFocus; + + /** + * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op + * (nothing is typed). By default this action sends a tap event to the center of the view to + * attain focus before typing. + * + * @param stringToBeTyped String To be typed by {@link TypeTextAction} + */ + public TypeTextAction(String stringToBeTyped) { + this(stringToBeTyped, true); + } + + /** + * Constructs {@link TypeTextAction} with given string. If the string is empty it results in no-op + * (nothing is typed). + * + * @param stringToBeTyped String To be typed by {@link TypeTextAction} + * @param tapToFocus indicates whether a tap should be sent to the underlying view before typing. + */ + public TypeTextAction(String stringToBeTyped, boolean tapToFocus) { + checkNotNull(stringToBeTyped); + this.stringToBeTyped = stringToBeTyped; + this.tapToFocus = tapToFocus; + } + + @SuppressWarnings("unchecked") + @Override + public Matcher<View> getConstraints() { + Matcher<View> matchers = allOf(isDisplayed()); + if (!tapToFocus) { + matchers = allOf(matchers, hasFocus()); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB) { + return allOf(matchers, supportsInputMethods()); + } else { + // SearchView does not support input methods itself (rather it delegates to an internal text + // view for input). + return allOf(matchers, anyOf(supportsInputMethods(), isAssignableFrom(SearchView.class))); + } + } + + @Override + public void perform(UiController uiController, View view) { + // No-op if string is empty. + if (stringToBeTyped.length() == 0) { + Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); + return; + } + + if (tapToFocus) { + // Perform a click. + new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER) + .perform(uiController, view); + uiController.loopMainThreadUntilIdle(); + } + + try { + if (!uiController.injectString(stringToBeTyped)) { + Log.e(TAG, "Failed to type text: " + stringToBeTyped); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new RuntimeException("Failed to type text: " + stringToBeTyped)) + .build(); + } + } catch (InjectEventSecurityException e) { + Log.e(TAG, "Failed to type text: " + stringToBeTyped); + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(e) + .build(); + } + } + + @Override + public String getDescription() { + return "type text"; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java new file mode 100644 index 0000000..07b0e3d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java @@ -0,0 +1,223 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.action; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.ViewAction; + +import android.view.KeyEvent; + +/** + * A collection of common {@link ViewActions}. + */ +public final class ViewActions { + + private ViewActions() {} + + /** + * Returns an action that clears text on the view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction clearText() { + return new ClearTextAction(); + } + + /** + * Returns an action that clicks the view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction click() { + return new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that performs a single click on the view. + * + * If the click takes longer than the 'long press' duration (which is possible) the provided + * rollback action is invoked on the view and a click is attempted again. + * + * This is only necessary if the view being clicked on has some different behaviour for long press + * versus a normal tap. + * + * For example - if a long press on a particular view element opens a popup menu - + * ViewActions.pressBack() may be an acceptable rollback action. + * + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen</li> + * <li>any constraints of the rollbackAction</li> + * <ul> + */ + public static ViewAction click(ViewAction rollbackAction) { + checkNotNull(rollbackAction); + return new GeneralClickAction(Tap.SINGLE, GeneralLocation.CENTER, Press.FINGER, + rollbackAction); + } + + /** + * Returns an action that performs a swipe right-to-left across the vertical center of the + * view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction swipeLeft() { + return new GeneralSwipeAction(Swipe.FAST, GeneralLocation.CENTER_RIGHT, + GeneralLocation.CENTER_LEFT, Press.FINGER); + } + + /** + * Returns an action that performs a swipe left-to-right across the vertical center of the + * view.<br> + * <br> + * View constraints: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction swipeRight() { + return new GeneralSwipeAction(Swipe.FAST, GeneralLocation.CENTER_LEFT, + GeneralLocation.CENTER_RIGHT, Press.FINGER); + } + + /** + * Returns an action that closes soft keyboard. If the keyboard is already closed, it is a no-op. + */ + public static ViewAction closeSoftKeyboard() { + return new CloseKeyboardAction(); + } + + /** + * Returns an action that presses the current action button (next, done, search, etc) on the IME + * (Input Method Editor). The selected view will have its onEditorAction method called. + */ + public static ViewAction pressImeActionButton() { + return new EditorAction(); + } + + /** + * Returns an action that clicks the back button. + */ + public static ViewAction pressBack() { + return pressKey(KeyEvent.KEYCODE_BACK); + } + + /** + * Returns an action that presses the hardware menu key. + */ + public static ViewAction pressMenuKey() { + return pressKey(KeyEvent.KEYCODE_MENU); + } + + /** + * Returns an action that presses the key specified by the keyCode (eg. Keyevent.KEYCODE_BACK). + */ + public static ViewAction pressKey(int keyCode) { + return new KeyEventAction(new EspressoKey.Builder().withKeyCode(keyCode).build()); + } + + /** + * Returns an action that presses the specified key with the specified modifiers. + */ + public static ViewAction pressKey(EspressoKey key) { + return new KeyEventAction(key); + } + + /** + * Returns an action that double clicks the view.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction doubleClick() { + return new GeneralClickAction(Tap.DOUBLE, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that long clicks the view.<br> + * + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <ul> + */ + public static ViewAction longClick() { + return new GeneralClickAction(Tap.LONG, GeneralLocation.CENTER, Press.FINGER); + } + + /** + * Returns an action that scrolls to the view.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be a descendant of ScrollView + * <li>must have visibility set to View.VISIBLE + * <ul> + */ + public static ViewAction scrollTo() { + return new ScrollToAction(); + } + + /** + * Returns an action that types the provided string into the view. + * Appending a \n to the end of the string translates to a ENTER key event. Note: this method + * does not change cursor position in the focused view - text is inserted at the location where + * the cursor is currently pointed.<br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <li>must support input methods + * <li>must be already focused + * <ul> + */ + public static ViewAction typeTextIntoFocusedView(String stringToBeTyped) { + return new TypeTextAction(stringToBeTyped, false /* tapToFocus */); + } + + /** + * Returns an action that selects the view (by clicking on it) and types the provided string into + * the view. Appending a \n to the end of the string translates to a ENTER key event. Note: this + * method performs a tap on the view before typing to force the view into focus, if the view + * already contains text this tap may place the cursor at an arbitrary position within the text. + * <br> + * <br> + * View preconditions: + * <ul> + * <li>must be displayed on screen + * <li>must support input methods + * <ul> + */ + public static ViewAction typeText(String stringToBeTyped) { + return new TypeTextAction(stringToBeTyped); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java new file mode 100644 index 0000000..eb14861 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.assertion; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers.assertThat; +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewAssertion; +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Preconditions; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; + +import android.util.Log; +import android.view.View; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * A collection of common {@link ViewAssertion}s. + */ +public final class ViewAssertions { + + private static final String TAG = ViewAssertions.class.getSimpleName(); + + + private ViewAssertions() {} + + /** + * Returns an assert that ensures the view matcher does not find any matching view in the + * hierarchy. + */ + public static ViewAssertion doesNotExist() { + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noView) { + if (view != null) { + assertThat("View is present in the hierarchy: " + HumanReadables.describe(view), true, + is(false)); + } + } + }; + } + + /** + * Returns a generic {@link ViewAssertion} that asserts that a view exists in the view hierarchy + * and is matched by the given view matcher. + */ + public static ViewAssertion matches(final Matcher<? super View> viewMatcher) { + checkNotNull(viewMatcher); + return new ViewAssertion() { + @Override + public void check(View view, NoMatchingViewException noViewException) { + StringDescription description = new StringDescription(); + description.appendText("'"); + viewMatcher.describeTo(description); + if (noViewException != null) { + description.appendText(String.format( + "' check could not be performed because view '%s' was not found.\n", viewMatcher)); + Log.e(TAG, description.toString()); + throw noViewException; + } else { + // TODO(valeraz): ideally, we should append the matcher used to find the view + // This can be done in DefaultFailureHandler (just like we currently to with + // PerformException) + description.appendText("' doesn't match the selected view."); + assertThat(description.toString(), view, viewMatcher); + } + } + }; + } + + + /** + * Returns a generic {@link ViewAssertion} that asserts that the descendant views selected by the + * selector match the specified matcher. + * + * Example: onView(rootView).check(selectedDescendantsMatch( + * not(isAssignableFrom(TextView.class)), hasContentDescription())); + */ + public static ViewAssertion selectedDescendantsMatch( + final Matcher<View> selector, final Matcher<View> matcher) { + return new ViewAssertion() { + @SuppressWarnings("unchecked") + @Override + public void check(View view, NoMatchingViewException noViewException) { + Preconditions.checkNotNull(view); + + final Predicate<View> viewPredicate = new Predicate<View>() { + @Override + public boolean apply(View input) { + return selector.matches(input); + } + }; + + Iterator<View> selectedViewIterator = + Iterables.filter(breadthFirstViewTraversal(view), viewPredicate).iterator(); + + List<View> nonMatchingViews = new ArrayList<View>(); + while (selectedViewIterator.hasNext()) { + View selectedView = selectedViewIterator.next(); + + if (!matcher.matches(selectedView)) { + nonMatchingViews.add(selectedView); + } + } + + if (nonMatchingViews.size() > 0) { + String errorMessage = HumanReadables.getViewHierarchyErrorMessage(view, + nonMatchingViews, + String.format("At least one view did not match the required matcher: %s", matcher), + "****DOES NOT MATCH****"); + throw new AssertionFailedError(errorMessage); + } + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java new file mode 100644 index 0000000..082c045 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java @@ -0,0 +1,208 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Provides a way to monitor AsyncTask's work queue to ensure that there is no work pending + * or executing (and to allow notification of idleness). + * + * This class is based on the assumption that we can get at the ThreadPoolExecutor AsyncTask uses. + * That is currently possible and easy in Froyo to JB. If it ever becomes impossible, as long as we + * know the max # of executor threads the AsyncTask framework allows we can still use this + * interface, just need a different implementation. + */ +class AsyncTaskPoolMonitor { + private final AtomicReference<IdleMonitor> monitor = new AtomicReference<IdleMonitor>(null); + private final ThreadPoolExecutor pool; + private final AtomicInteger activeBarrierChecks = new AtomicInteger(0); + + AsyncTaskPoolMonitor(ThreadPoolExecutor pool) { + this.pool = checkNotNull(pool); + } + + /** + * Checks if the pool is idle at this moment. + * + * @return true if the pool is idle, false otherwise. + */ + boolean isIdleNow() { + if (!pool.getQueue().isEmpty()) { + return false; + } else { + int activeCount = pool.getActiveCount(); + if (0 != activeCount) { + if (monitor.get() == null) { + // if there's no idle monitor scheduled and there are still barrier + // checks running, they are about to exit, ignore them. + activeCount = activeCount - activeBarrierChecks.get(); + } + } + return 0 == activeCount; + } + } + + /** + * Notifies caller once the pool is idle. + * + * We check for idle-ness by submitting the max # of tasks the pool will take and blocking + * the tasks until they are all executing. Then we know there are no other tasks _currently_ + * executing in the pool, we look back at the work queue to see if its backed up, if it is + * we reenqueue ourselves and try again. + * + * Obviously this strategy will fail horribly if 2 parties are doing it at the same time, + * we prevent recursion here the best we can. + * + * @param idleCallback called once the pool is idle. + */ + void notifyWhenIdle(final Runnable idleCallback) { + checkNotNull(idleCallback); + IdleMonitor myMonitor = new IdleMonitor(idleCallback); + checkState(monitor.compareAndSet(null, myMonitor), "cannot monitor for idle recursively!"); + myMonitor.monitorForIdle(); + } + + /** + * Stops the idle monitoring mechanism if it is in place. + * + * Note: the callback may still be invoked after this method is called. The only thing + * this method guarantees is that we will stop/cancel any blockign tasks we've placed + * on the thread pool. + */ + void cancelIdleMonitor() { + IdleMonitor myMonitor = monitor.getAndSet(null); + if (null != myMonitor) { + myMonitor.poison(); + } + } + + private class IdleMonitor { + private final Runnable onIdle; + private final AtomicInteger barrierGeneration = new AtomicInteger(0); + private final CyclicBarrier barrier; + // written by main, read by all. + private volatile boolean poisoned; + + private IdleMonitor(final Runnable onIdle) { + this.onIdle = checkNotNull(onIdle); + this.barrier = new CyclicBarrier(pool.getCorePoolSize(), + new Runnable() { + @Override + public void run() { + if (pool.getQueue().isEmpty()) { + // no one is behind us, so the queue is idle! + monitor.compareAndSet(IdleMonitor.this, null); + onIdle.run(); + } else { + // work is waiting behind us, enqueue another block of tasks and + // hopefully when they're all running, the queue will be empty. + monitorForIdle(); + } + + } + }); + } + + /** + * Stops this monitor from using the thread pool's resources, it may still cause the + * callback to be executed though. + */ + private void poison() { + poisoned = true; + barrier.reset(); + } + + private void monitorForIdle() { + if (poisoned) { + return; + } + + if (isIdleNow()) { + monitor.compareAndSet(this, null); + onIdle.run(); + } else { + // Submit N tasks that will block until they are all running on the thread pool. + // at this point we can check the pool's queue and verify that there are no new + // tasks behind us and deem the queue idle. + + int poolSize = pool.getCorePoolSize(); + final BarrierRestarter restarter = new BarrierRestarter(barrier, barrierGeneration); + + for (int i = 0; i < poolSize; i++) { + pool.execute(new Runnable() { + @Override + public void run() { + while (!poisoned) { + activeBarrierChecks.incrementAndGet(); + int myGeneration = barrierGeneration.get(); + try { + barrier.await(); + return; + } catch (InterruptedException ie) { + // sorry - I cant let you interrupt me! + restarter.restart(myGeneration); + } catch (BrokenBarrierException bbe) { + restarter.restart(myGeneration); + } finally { + activeBarrierChecks.decrementAndGet(); + } + } + } + }); + } + } + } + } + + + private static class BarrierRestarter { + private final CyclicBarrier barrier; + private final AtomicInteger barrierGeneration; + BarrierRestarter(CyclicBarrier barrier, AtomicInteger barrierGeneration) { + this.barrier = barrier; + this.barrierGeneration = barrierGeneration; + } + + /** + * restarts the barrier. + * + * After the calling this function it is guaranteed that barrier generation has been incremented + * and the barrier can be awaited on again. + * + * @param fromGeneration the generation that encountered the breaking exception. + */ + synchronized void restart(int fromGeneration) { + // must be synchronized. T1 could pass the if check, be suspended before calling reset, T2 + // sails thru - and awaits on the barrier again before T1 has awoken and reset it. + int nextGen = fromGeneration + 1; + if (barrierGeneration.compareAndSet(fromGeneration, nextGen)) { + // first time we've seen fromGeneration request a reset. lets reset the barrier. + barrier.reset(); + } else { + // some other thread has already reset the barrier - this request is a no op. + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java new file mode 100644 index 0000000..6615b1d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.InstrumentationRegistry; +import com.google.android.apps.common.testing.testrunner.inject.TargetContext; +import com.google.android.apps.common.testing.ui.espresso.FailureHandler; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.base.Optional; + +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import dagger.Module; +import dagger.Provides; + +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Dagger module for creating the implementation classes within the base package. + */ +@Module(library = true, injects = { + BaseLayerModule.FailureHandlerHolder.class, FailureHandler.class}) +public class BaseLayerModule { + + @Provides @Singleton + public ActivityLifecycleMonitor provideLifecycleMonitor() { + // TODO(user): replace with installation of AndroidInstrumentationModule once + // proguard issues resolved. + return ActivityLifecycleMonitorRegistry.getInstance(); + } + + @Provides @TargetContext + public Context provideTargetContext() { + // TODO(user): replace with installation of AndroidInstrumentationModule once + // proguard issues resolved. + return InstrumentationRegistry.getInstance().getTargetContext(); + } + + @Provides @Singleton + public Looper provideMainLooper() { + return Looper.getMainLooper(); + } + + @Provides + public UiController provideUiController(UiControllerImpl uiControllerImpl) { + return uiControllerImpl; + } + + @Provides @Singleton @CompatAsyncTask + public Optional<AsyncTaskPoolMonitor> provideCompatAsyncTaskMonitor( + ThreadPoolExecutorExtractor extractor) { + Optional<ThreadPoolExecutor> compatThreadPool = extractor.getCompatAsyncTaskThreadPool(); + if (compatThreadPool.isPresent()) { + return Optional.of(new AsyncTaskPoolMonitor(compatThreadPool.get())); + } else { + return Optional.<AsyncTaskPoolMonitor>absent(); + } + } + + @Provides @Singleton @MainThread + public Executor provideMainThreadExecutor(Looper mainLooper) { + final Handler handler = new Handler(mainLooper); + return new Executor() { + @Override + public void execute(Runnable runnable) { + handler.post(runnable); + } + }; + } + + @Provides @Singleton @SdkAsyncTask + public AsyncTaskPoolMonitor provideSdkAsyncTaskMonitor(ThreadPoolExecutorExtractor extractor) { + return new AsyncTaskPoolMonitor(extractor.getAsyncTaskThreadPool()); + + } + + @Provides + public List<Root> provideKnownRoots(RootsOracle rootsOracle) { + // RootsOracle acts as a provider, but returning Providers is illegal, so delegate. + return rootsOracle.get(); + } + + @Provides @Singleton + public EventInjector provideEventInjector() { + // On API 16 and above, android uses input manager to inject events. On API < 16, + // they use Window Manager. So we need to create our InjectionStrategy depending on the api + // level. Instrumentation does not check if the event presses went through by checking the + // boolean return value of injectInputEvent, which is why we created this class to better + // handle lost/dropped press events. Instrumentation cannot be used as a fallback strategy, + // since this will be executed on the main thread. + int sdkVersion = Build.VERSION.SDK_INT; + EventInjectionStrategy injectionStrategy = null; + if (sdkVersion >= 16) { // Use InputManager for API level 16 and up. + InputManagerEventInjectionStrategy strategy = new InputManagerEventInjectionStrategy(); + strategy.initialize(); + injectionStrategy = strategy; + } else if (sdkVersion >= 7) { + // else Use WindowManager for API level 15 through 7. + WindowManagerEventInjectionStrategy strategy = new WindowManagerEventInjectionStrategy(); + strategy.initialize(); + injectionStrategy = strategy; + } else { + throw new RuntimeException( + "API Level 6 and below is not supported. You are running: " + sdkVersion); + } + return new EventInjector(injectionStrategy); + } + + /** + * Holder for AtomicReference<FailureHandler> which allows updating it at runtime. + */ + @Singleton + public static class FailureHandlerHolder { + private final AtomicReference<FailureHandler> holder; + + @Inject + public FailureHandlerHolder(@Default FailureHandler defaultHandler) { + holder = new AtomicReference<FailureHandler>(defaultHandler); + } + + public void update(FailureHandler handler) { + holder.set(handler); + } + + public FailureHandler get() { + return holder.get(); + } + } + + @Provides + FailureHandler provideFailureHandler(FailureHandlerHolder holder) { + return holder.get(); + } + + @Provides + @Default + FailureHandler provideFailureHander(DefaultFailureHandler impl) { + return impl; + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java new file mode 100644 index 0000000..11ec6ab --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a AsyncTaskMonitor as monitoring the CompatAsyncTask pool + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@interface CompatAsyncTask { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java new file mode 100644 index 0000000..b54440d --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a default provider. + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +public @interface Default { +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java new file mode 100644 index 0000000..b1e43da --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.testrunner.inject.TargetContext; +import com.google.android.apps.common.testing.ui.espresso.EspressoException; +import com.google.android.apps.common.testing.ui.espresso.FailureHandler; +import com.google.android.apps.common.testing.ui.espresso.PerformException; + +import android.content.Context; +import android.view.View; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; + +import java.util.concurrent.atomic.AtomicInteger; + +import javax.inject.Inject; + +/** + * Espresso's default {@link FailureHandler}. If this does not fit your needs, feel free to provide + * your own implementation via Espresso.setFailureHandler(FailureHandler). + */ +public final class DefaultFailureHandler implements FailureHandler { + + private static final AtomicInteger failureCount = new AtomicInteger(0); + private final Context appContext; + + @Inject + public DefaultFailureHandler(@TargetContext Context appContext) { + this.appContext = checkNotNull(appContext); + } + + @Override + public void handle(Throwable error, Matcher<View> viewMatcher) { + if (error instanceof EspressoException || error instanceof AssertionFailedError + || error instanceof AssertionError) { + throw propagate(getUserFriendlyError(error, viewMatcher)); + } else { + throw propagate(error); + } + } + + /** + * When the error is coming from espresso, it is more user friendly to: + * 1. propagate assertions as assertions + * 2. swap the stack trace of the error to that of current thread (which will show + * directly where the actual problem is) + */ + private Throwable getUserFriendlyError(Throwable error, Matcher<View> viewMatcher) { + if (error instanceof PerformException) { + // Re-throw the exception with the viewMatcher (used to locate the view) as the view + // description (makes the error more readable). The reason we do this here: not all creators + // of PerformException have access to the viewMatcher. + throw new PerformException.Builder() + .from((PerformException) error) + .withViewDescription(viewMatcher.toString()) + .build(); + } + + if (error instanceof AssertionError) { + // reports Failure instead of Error. + // assertThat(...) throws an AssertionFailedError. + error = new AssertionFailedWithCauseError(error.getMessage(), error); + } + + error.setStackTrace(Thread.currentThread().getStackTrace()); + return error; + } + + private static final class AssertionFailedWithCauseError extends AssertionFailedError { + /* junit hides the cause constructor. */ + public AssertionFailedWithCauseError(String message, Throwable cause) { + super(message); + initCause(cause); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java new file mode 100644 index 0000000..3378197 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Injects Events into the application under test. Implementors should expect to be called + * from the UI thread and are responsible for ensuring the event gets delivered or indicating that + * it could not be delivered. + */ +interface EventInjectionStrategy { + /** + * Injects the given {@link KeyEvent} into the android system. + * + * @param keyEvent The event to inject + * @return {@code true} if the input was inject successfully, {@code false} otherwise. + * @throws InjectEventSecurityException if the MotionEvent would be delivered to an area of the + * screen that is not owned by the application under test. + */ + boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException; + + /** + * Injects the given {@link MotionEvent} into the android system. + * + * @param motionEvent The event to inject + * @return {@code true} if the input was inject successfully, {@code false} otherwise. + * @throws InjectEventSecurityException if the MotionEvent would be delivered to an area of the + * screen that is not owned by the application under test. + */ + boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException; + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java new file mode 100644 index 0000000..0728331 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; + +/** + * Responsible for selecting the proper strategy for injecting MotionEvents to the application under + * test. + */ +final class EventInjector { + private static final String TAG = EventInjector.class.getSimpleName(); + private final EventInjectionStrategy injectionStrategy; + + EventInjector(EventInjectionStrategy injectionStrategy) { + this.injectionStrategy = checkNotNull(injectionStrategy); + } + + boolean injectKeyEvent(KeyEvent event) throws InjectEventSecurityException { + long downTime = event.getDownTime(); + long eventTime = event.getEventTime(); + int action = event.getAction(); + int code = event.getKeyCode(); + int repeatCount = event.getRepeatCount(); + int metaState = event.getMetaState(); + int deviceId = event.getDeviceId(); + int scancode = event.getScanCode(); + int flags = event.getFlags(); + + if (eventTime == 0) { + eventTime = SystemClock.uptimeMillis(); + } + + if (downTime == 0) { + downTime = eventTime; + } + + // API < 9 does not have constructor with source (nor has source field). + KeyEvent newEvent; + if (Build.VERSION.SDK_INT < 9) { + newEvent = new KeyEvent(downTime, + eventTime, + action, + code, + repeatCount, + metaState, + deviceId, + scancode, + flags | KeyEvent.FLAG_FROM_SYSTEM); + } else { + int source = event.getSource(); + newEvent = new KeyEvent(downTime, + eventTime, + action, + code, + repeatCount, + metaState, + deviceId, + scancode, + flags | KeyEvent.FLAG_FROM_SYSTEM, + source); + } + + Log.v( + "ESP_TRACE", + String.format( + "%s:Injecting event for character (%c) with key code (%s) downtime: (%s)", TAG, + newEvent.getUnicodeChar(), newEvent.getKeyCode(), newEvent.getDownTime())); + + return injectionStrategy.injectKeyEvent(newEvent); + } + + boolean injectMotionEvent(MotionEvent event) throws InjectEventSecurityException { + return injectionStrategy.injectMotionEvent(event); + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java new file mode 100644 index 0000000..e390f0f --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicies; +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicy; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource.ResourceCallback; +import com.google.common.collect.Lists; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.util.Log; + +import java.util.BitSet; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Keeps track of user-registered {@link IdlingResource}s. + */ +@Singleton +public final class IdlingResourceRegistry { + private static final String TAG = IdlingResourceRegistry.class.getSimpleName(); + + private static final int DYNAMIC_RESOURCE_HAS_IDLED = 1; + private static final int TIMEOUT_OCCURRED = 2; + private static final int IDLE_WARNING_REACHED = 3; + private static final int POSSIBLE_RACE_CONDITION_DETECTED = 4; + private static final Object TIMEOUT_MESSAGE_TAG = new Object(); + + private static final IdleNotificationCallback NO_OP_CALLBACK = new IdleNotificationCallback() { + + @Override + public void allResourcesIdle() {} + + @Override + public void resourcesStillBusyWarning(List<String> busys) {} + + @Override + public void resourcesHaveTimedOut(List<String> busys) {} + }; + + // resources and idleState should only be accessed on main thread + private final List<IdlingResource> resources = Lists.newArrayList(); + // idleState.get(i) == true indicates resources.get(i) is idle, false indicates it's busy + private final BitSet idleState = new BitSet(); + private final Looper looper; + private final Handler handler; + private final Dispatcher dispatcher; + private IdleNotificationCallback idleNotificationCallback = NO_OP_CALLBACK; + + @Inject + public IdlingResourceRegistry(Looper looper) { + this.looper = looper; + this.dispatcher = new Dispatcher(); + this.handler = new Handler(looper, dispatcher); + } + + /** + * Registers the given resource. + */ + public void register(final IdlingResource resource) { + checkNotNull(resource); + if (Looper.myLooper() != looper) { + handler.post(new Runnable() { + @Override + public void run() { + register(resource); + } + }); + } else { + for (IdlingResource oldResource : resources) { + if (resource.getName().equals(oldResource.getName())) { + // This does not throw an error to avoid leaving tests that register resource in test + // setup in an undeterministic state (we cannot assume that everyone clears vm state + // between each test run) + Log.e(TAG, String.format("Attempted to register resource with same names:" + + " %s. R1: %s R2: %s.\nDuplicate resource registration will be ignored.", + resource.getName(), resource, oldResource)); + return; + } + } + resources.add(resource); + final int position = resources.size() - 1; + registerToIdleCallback(resource, position); + idleState.set(position, resource.isIdleNow()); + } + } + + public void registerLooper(Looper looper, boolean considerWaitIdle) { + checkNotNull(looper); + checkArgument(Looper.getMainLooper() != looper, "Not intended for use with main looper!"); + register(new LooperIdlingResource(looper, considerWaitIdle)); + } + + private void registerToIdleCallback(IdlingResource resource, final int position) { + resource.registerIdleTransitionCallback(new ResourceCallback() { + @Override + public void onTransitionToIdle() { + Message m = handler.obtainMessage(DYNAMIC_RESOURCE_HAS_IDLED); + m.arg1 = position; + handler.sendMessage(m); + } + }); + } + + boolean allResourcesAreIdle() { + checkState(Looper.myLooper() == looper); + for (int i = idleState.nextSetBit(0); i >= 0 && i < resources.size(); + i = idleState.nextSetBit(i + 1)) { + idleState.set(i, resources.get(i).isIdleNow()); + } + return idleState.cardinality() == resources.size(); + } + + interface IdleNotificationCallback { + public void allResourcesIdle(); + + public void resourcesStillBusyWarning(List<String> busyResourceNames); + + public void resourcesHaveTimedOut(List<String> busyResourceNames); + } + + void notifyWhenAllResourcesAreIdle(IdleNotificationCallback callback) { + checkNotNull(callback); + checkState(Looper.myLooper() == looper); + checkState(idleNotificationCallback == NO_OP_CALLBACK, "Callback has already been registered."); + if (allResourcesAreIdle()) { + callback.allResourcesIdle(); + } else { + idleNotificationCallback = callback; + scheduleTimeoutMessages(); + } + } + + void cancelIdleMonitor() { + dispatcher.deregister(); + } + + private void scheduleTimeoutMessages() { + IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + Message timeoutWarning = handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG); + handler.sendMessageDelayed(timeoutWarning, warning.getIdleTimeoutUnit().toMillis( + warning.getIdleTimeout())); + Message timeoutError = handler.obtainMessage(TIMEOUT_OCCURRED, TIMEOUT_MESSAGE_TAG); + IdlingPolicy error = IdlingPolicies.getDynamicIdlingResourceErrorPolicy(); + + handler.sendMessageDelayed(timeoutError, error.getIdleTimeoutUnit().toMillis( + error.getIdleTimeout())); + } + + private List<String> getBusyResources() { + List<String> busyResourceNames = Lists.newArrayList(); + List<Integer> racyResources = Lists.newArrayList(); + + for (int i = 0; i < resources.size(); i++) { + IdlingResource resource = resources.get(i); + if (!idleState.get(i)) { + if (resource.isIdleNow()) { + // We have not been notified of a BUSY -> IDLE transition, but the resource is telling us + // its that its idle. Either it's a race condition or is this resource buggy. + racyResources.add(i); + } else { + busyResourceNames.add(resource.getName()); + } + } + } + + if (!racyResources.isEmpty()) { + Message raceBuster = handler.obtainMessage(POSSIBLE_RACE_CONDITION_DETECTED, + TIMEOUT_MESSAGE_TAG); + raceBuster.obj = racyResources; + handler.sendMessage(raceBuster); + return null; + } else { + return busyResourceNames; + } + } + + + private class Dispatcher implements Handler.Callback { + @Override + public boolean handleMessage(Message m) { + switch (m.what) { + case DYNAMIC_RESOURCE_HAS_IDLED: + handleResourceIdled(m); + break; + case IDLE_WARNING_REACHED: + handleTimeoutWarning(); + break; + case TIMEOUT_OCCURRED: + handleTimeout(); + break; + case POSSIBLE_RACE_CONDITION_DETECTED: + handleRaceCondition(m); + break; + default: + Log.w(TAG, "Unknown message type: " + m); + return false; + } + return true; + } + + private void handleResourceIdled(Message m) { + idleState.set(m.arg1, true); + if (idleState.cardinality() == resources.size()) { + try { + idleNotificationCallback.allResourcesIdle(); + } finally { + deregister(); + } + } + } + + private void handleTimeoutWarning() { + List<String> busyResources = getBusyResources(); + if (busyResources == null) { + // null indicates that there is either a race or a programming error + // a race detector message has been inserted into the q. + // reinsert the idle_warning_reached message into the q directly after it + // so we generate warnings if the system is still sane. + handler.sendMessage(handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG)); + } else { + IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + idleNotificationCallback.resourcesStillBusyWarning(busyResources); + handler.sendMessageDelayed( + handler.obtainMessage(IDLE_WARNING_REACHED, TIMEOUT_MESSAGE_TAG), + warning.getIdleTimeoutUnit().toMillis(warning.getIdleTimeout())); + } + } + + private void handleTimeout() { + List<String> busyResources = getBusyResources(); + if (busyResources == null) { + // detected a possible race... we've enqueued a race busting message + // so either that'll resolve the race or kill the app because it's buggy. + // if the race resolves, we need to timeout properly. + handler.sendMessage(handler.obtainMessage(TIMEOUT_OCCURRED, TIMEOUT_MESSAGE_TAG)); + } else { + try { + idleNotificationCallback.resourcesHaveTimedOut(busyResources); + } finally { + deregister(); + } + } + } + + @SuppressWarnings("unchecked") + private void handleRaceCondition(Message m) { + for (Integer i : (List<Integer>) m.obj) { + if (idleState.get(i)) { + // it was a race... i is now idle, everything is fine... + } else { + throw new IllegalStateException(String.format( + "Resource %s isIdleNow() is returning true, but a message indicating that the " + + "resource has transitioned from busy to idle was never sent.", + resources.get(i).getName())); + } + } + } + + private void deregister() { + handler.removeCallbacksAndMessages(TIMEOUT_MESSAGE_TAG); + idleNotificationCallback = NO_OP_CALLBACK; + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java new file mode 100644 index 0000000..d324795 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.util.Log; +import android.view.InputDevice; +import android.view.InputEvent; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * An {@link EventInjectionStrategy} that uses the input manager to inject Events. + * This strategy supports API level 16 and above. + */ +final class InputManagerEventInjectionStrategy implements EventInjectionStrategy { + private static final String TAG = InputManagerEventInjectionStrategy.class.getSimpleName(); + + // Used in reflection + private boolean initComplete; + private Method injectInputEventMethod; + private Method setSourceMotionMethod; + private Object instanceInputManagerObject; + private int motionEventMode; + private int keyEventMode; + + InputManagerEventInjectionStrategy() { + checkState(Build.VERSION.SDK_INT >= 16, "Unsupported API level."); + } + + void initialize() { + if (initComplete) { + return; + } + + try { + Log.d(TAG, "Creating injection strategy with input manager."); + + // Get the InputputManager class object and initialize if necessary. + Class<?> inputManagerClassObject = Class.forName("android.hardware.input.InputManager"); + Method getInstanceMethod = inputManagerClassObject.getDeclaredMethod("getInstance"); + getInstanceMethod.setAccessible(true); + + instanceInputManagerObject = getInstanceMethod.invoke(inputManagerClassObject); + + injectInputEventMethod = instanceInputManagerObject.getClass() + .getDeclaredMethod("injectInputEvent", InputEvent.class, Integer.TYPE); + injectInputEventMethod.setAccessible(true); + + // Setting event mode to INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH to ensure + // that we've dispatched the event and any side effects its had on the view hierarchy + // have occurred. + Field motionEventModeField = + inputManagerClassObject.getField("INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH"); + motionEventModeField.setAccessible(true); + motionEventMode = motionEventModeField.getInt(inputManagerClassObject); + + Field keyEventModeField = + inputManagerClassObject.getField("INJECT_INPUT_EVENT_MODE_WAIT_FOR_FINISH"); + keyEventModeField.setAccessible(true); + keyEventMode = keyEventModeField.getInt(inputManagerClassObject); + + setSourceMotionMethod = MotionEvent.class.getDeclaredMethod("setSource", Integer.TYPE); + InputEvent.class.getDeclaredMethod("getSequenceNumber"); + initComplete = true; + } catch (ClassNotFoundException e) { + propagate(e); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + propagate(e); + } catch (NoSuchMethodException e) { + propagate(e); + } catch (SecurityException e) { + propagate(e); + } catch (NoSuchFieldException e) { + propagate(e); + } + } + + @Override + public boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException { + try { + return (Boolean) injectInputEventMethod.invoke(instanceInputManagerObject, + keyEvent, keyEventMode); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + @Override + public boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException { + try { + // Need to set the event source to touch screen, otherwise the input can be ignored even + // though injecting it would be successful. + // TODO(user): proper handling of events from a trackball (SOURCE_TRACKBALL) and joystick. + if ((motionEvent.getSource() & InputDevice.SOURCE_CLASS_POINTER) == 0 + && !isFromTouchpadInGlassDevice(motionEvent)) { + // Need to do runtime invocation of setSource because it was not added until 2.3_r1. + setSourceMotionMethod.invoke(motionEvent, InputDevice.SOURCE_TOUCHSCREEN); + } + return (Boolean) injectInputEventMethod.invoke(instanceInputManagerObject, + motionEvent, motionEventMode); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + // We'd like to inject non-pointer events sourced from touchpad in Glass. + private static boolean isFromTouchpadInGlassDevice(MotionEvent motionEvent) { + return (Build.DEVICE.contains("glass") + || Build.DEVICE.contains("Glass") || Build.DEVICE.contains("wingman")) + && ((motionEvent.getSource() & InputDevice.SOURCE_TOUCHPAD) != 0); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java new file mode 100644 index 0000000..b75fd36 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; +import com.google.android.apps.common.testing.ui.espresso.IdlingResource.ResourceCallback; +import com.google.android.apps.common.testing.ui.espresso.base.QueueInterrogator.QueueState; + +import android.os.Handler; +import android.os.Looper; +import android.os.MessageQueue.IdleHandler; + +/** + * An Idling Resource Adapter for Loopers. + */ +final class LooperIdlingResource implements IdlingResource { + + private static final String TAG = "LooperIdleResource"; + + private final boolean considerWaitIdle; + private final Looper monitoredLooper; + private final Handler monitoredHandler; + + private ResourceCallback resourceCallback; + + LooperIdlingResource(Looper monitoredLooper, boolean considerWaitIdle) { + this.monitoredLooper = checkNotNull(monitoredLooper); + this.monitoredHandler = new Handler(monitoredLooper); + this.considerWaitIdle = considerWaitIdle; + checkState(Looper.getMainLooper() != monitoredLooper, "Not for use with main looper."); + } + + // Only assigned and read from the main loop. + private QueueInterrogator queueInterrogator; + + @Override + public String getName() { + return monitoredLooper.getThread().getName(); + } + + @Override + public boolean isIdleNow() { + // on main thread here. + QueueState state = queueInterrogator.determineQueueState(); + boolean idle = state == QueueState.EMPTY || state == QueueState.TASK_DUE_LONG; + boolean idleWait = considerWaitIdle + && monitoredLooper.getThread().getState() == Thread.State.WAITING; + if (idleWait) { + if (resourceCallback != null) { + resourceCallback.onTransitionToIdle(); + } + } + return idle || idleWait; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback resourceCallback) { + this.resourceCallback = resourceCallback; + // on main thread here. + queueInterrogator = new QueueInterrogator(monitoredLooper); + + // must load idle handlers from monitored looper thread. + IdleHandler idleHandler = new ResourceCallbackIdleHandler(resourceCallback, queueInterrogator, + monitoredHandler); + + checkState(monitoredHandler.postAtFrontOfQueue(new Initializer(idleHandler)), + "Monitored looper exiting."); + } + + private static class ResourceCallbackIdleHandler implements IdleHandler { + private final ResourceCallback resourceCallback; + private final QueueInterrogator myInterrogator; + private final Handler myHandler; + + ResourceCallbackIdleHandler(ResourceCallback resourceCallback, + QueueInterrogator myInterrogator, Handler myHandler) { + this.resourceCallback = checkNotNull(resourceCallback); + this.myInterrogator = checkNotNull(myInterrogator); + this.myHandler = checkNotNull(myHandler); + } + + @Override + public boolean queueIdle() { + // invoked on the monitored looper thread. + QueueState queueState = myInterrogator.determineQueueState(); + if (queueState == QueueState.EMPTY || queueState == QueueState.TASK_DUE_LONG) { + // no block and no task coming 'shortly'. + resourceCallback.onTransitionToIdle(); + } else if (queueState == QueueState.BARRIER) { + // send a sentinal message that'll cause us to queueIdle again once the + // block is lifted. + myHandler.sendEmptyMessage(-1); + } + + return true; + } + } + + private static class Initializer implements Runnable { + private final IdleHandler myIdleHandler; + + Initializer(IdleHandler myIdleHandler) { + this.myIdleHandler = checkNotNull(myIdleHandler); + } + + @Override + public void run() { + // on monitored looper thread. + Looper.myQueue().addIdleHandler(myIdleHandler); + } + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java new file mode 100644 index 0000000..c431f48 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates an Executor that executes tasks on the main thread + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +public @interface MainThread { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java new file mode 100644 index 0000000..cd63eb8 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.os.SystemClock; +import android.util.Log; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * Isolates the nasty details of touching the message queue. + */ +final class QueueInterrogator { + + enum QueueState { EMPTY, TASK_DUE_SOON, TASK_DUE_LONG, BARRIER }; + + private static final String TAG = "QueueInterrogator"; + + private static final Method messageQueueNextMethod; + private static final Field messageQueueHeadField; + private static final int LOOKAHEAD_MILLIS = 15; + + private final Looper interrogatedLooper; + private volatile MessageQueue interrogatedQueue; + + static { + Method nextMethod = null; + Field headField = null; + try { + nextMethod = MessageQueue.class.getDeclaredMethod("next"); + nextMethod.setAccessible(true); + + headField = MessageQueue.class.getDeclaredField("mMessages"); + headField.setAccessible(true); + } catch (IllegalArgumentException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (NoSuchFieldException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (NoSuchMethodException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } catch (SecurityException e) { + nextMethod = null; + headField = null; + Log.e(TAG, "Could not initialize interrogator!", e); + } finally { + messageQueueNextMethod = nextMethod; + messageQueueHeadField = headField; + } + } + + QueueInterrogator(Looper interrogatedLooper) { + this.interrogatedLooper = checkNotNull(interrogatedLooper); + checkNotNull(messageQueueHeadField); + checkNotNull(messageQueueNextMethod); + } + + // Only for use by espresso - keep package private. + Message getNextMessage() { + checkThread(); + + if (null == interrogatedQueue) { + initializeQueue(); + } + + try { + return (Message) messageQueueNextMethod.invoke(Looper.myQueue()); + } catch (IllegalAccessException e) { + throw propagate(e); + } catch (IllegalArgumentException e) { + throw propagate(e); + } catch (InvocationTargetException e) { + throw propagate(e); + } catch (SecurityException e) { + throw propagate(e); + } + } + + QueueState determineQueueState() { + // may be called from any thread. + + if (null == interrogatedQueue) { + initializeQueue(); + } + synchronized (interrogatedQueue) { + try { + Message head = (Message) messageQueueHeadField.get(interrogatedQueue); + if (null == head) { + // no messages pending - AT ALL! + return QueueState.EMPTY; + } + if (null == head.getTarget()) { + // null target is a sync barrier token. + return QueueState.BARRIER; + } else { + long headWhen = head.getWhen(); + long nowFuz = SystemClock.uptimeMillis() + LOOKAHEAD_MILLIS; + + if (nowFuz > headWhen) { + return QueueState.TASK_DUE_SOON; + } else { + return QueueState.TASK_DUE_LONG; + } + } + } catch (IllegalAccessException e) { + throw propagate(e); + } + } + } + + private void initializeQueue() { + if (interrogatedLooper == Looper.myLooper()) { + interrogatedQueue = Looper.myQueue(); + } else { + Handler oneShotHandler = new Handler(interrogatedLooper); + FutureTask<MessageQueue> queueCapture = new FutureTask<MessageQueue>( + new Callable<MessageQueue>() { + @Override + public MessageQueue call() { + return Looper.myQueue(); + } + }); + oneShotHandler.postAtFrontOfQueue(queueCapture); + try { + interrogatedQueue = queueCapture.get(); + } catch (ExecutionException ee) { + throw propagate(ee.getCause()); + } catch (InterruptedException ie) { + throw propagate(ie); + } + } + } + + private void checkThread() { + checkState(interrogatedLooper == Looper.myLooper(), "Calling from non-owning thread!"); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java new file mode 100644 index 0000000..6870aa3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers.isDialog; +import static com.google.android.apps.common.testing.ui.espresso.matcher.RootMatchers.isFocusable; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingRootException; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; + +import android.app.Activity; +import android.os.Looper; +import android.util.Log; +import android.view.View; + +import org.hamcrest.Matcher; + +import java.util.Collection; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +/** + * Provides the root View of the top-most Window, with which the user can interact. View is + * guaranteed to be in a stable state - i.e. not pending any updates from the application. + * + * This provider can only be accessed from the main thread. + */ +@Singleton +public final class RootViewPicker implements Provider<View> { + private static final String TAG = RootViewPicker.class.getSimpleName(); + + private final Provider<List<Root>> rootsOracle; + private final UiController uiController; + private final ActivityLifecycleMonitor activityLifecycleMonitor; + private final AtomicReference<Matcher<Root>> rootMatcherRef; + + private List<Root> roots; + + @Inject + RootViewPicker(Provider<List<Root>> rootsOracle, UiController uiController, + ActivityLifecycleMonitor activityLifecycleMonitor, + AtomicReference<Matcher<Root>> rootMatcherRef) { + this.rootsOracle = rootsOracle; + this.uiController = uiController; + this.activityLifecycleMonitor = activityLifecycleMonitor; + this.rootMatcherRef = rootMatcherRef; + } + + @Override + public View get() { + checkState(Looper.getMainLooper().equals(Looper.myLooper()), "must be called on main thread."); + Matcher<Root> rootMatcher = rootMatcherRef.get(); + + Root root = findRoot(rootMatcher); + + // we only want to propagate a root view that the user can interact with and is not + // about to relay itself out. An app should be in this state the majority of the time, + // if we happen not to be in this state at the moment, process the queue some more + // we should come to it quickly enough. + int loops = 0; + + while (!isReady(root)) { + if (loops < 3) { + uiController.loopMainThreadUntilIdle(); + } else if (loops < 1001) { + + // loopUntil idle effectively is polling and pegs the CPU... if we don't have an update to + // process immediately, we might have something coming very very soon. + uiController.loopMainThreadForAtLeast(10); + } else { + // we've waited for the root view to be fully laid out and have window focus + // for over 10 seconds. something is wrong. + throw new RuntimeException(String.format("Waited for the root of the view hierarchy to have" + + " window focus and not be requesting layout for over 10 seconds. If you specified a" + + " non default root matcher, it may be picking a root that never takes focus." + + " Otherwise, something is seriously wrong. Selected Root:\n%s\n. All Roots:\n%s" + , root, Joiner.on("\n").join(roots))); + } + + root = findRoot(rootMatcher); + loops++; + } + + return root.getDecorView(); + } + + private boolean isReady(Root root) { + // Root is ready (i.e. UI is no longer in flux) if layout of the root view is not being + // requested and the root view has window focus (if it is focusable). + View rootView = root.getDecorView(); + if (!rootView.isLayoutRequested()) { + return rootView.hasWindowFocus() || !isFocusable().matches(root); + } + return false; + } + + private Root findRoot(Matcher<Root> rootMatcher) { + waitForAtLeastOneActivityToBeResumed(); + + roots = rootsOracle.get(); + + // TODO(user): move these checks into the RootsOracle. + if (roots.isEmpty()) { + // Reflection broke + throw new RuntimeException("No root window were discovered."); + } + + if (roots.size() > 1) { + // Multiple roots only occur: + // when multiple activities are in some state of their lifecycle in the application + // - we don't care about this, since we only want to interact with the RESUMED + // activity, all other activities windows are not visible to the user so, out of + // scope. + // when a PopupWindow or PopupMenu is used + // - this is a case where we definitely want to consider the top most window, since + // it probably has the most useful info in it. + // when an android.app.dialog is shown + // - again, this is getting all the users attention, so it gets the test attention + // too. + if (Log.isLoggable(TAG, Log.VERBOSE)) { + Log.v(TAG, String.format("Multiple windows detected: %s", roots)); + } + } + + List<Root> selectedRoots = Lists.newArrayList(); + for (Root root : roots) { + if (rootMatcher.matches(root)) { + selectedRoots.add(root); + } + } + + if (selectedRoots.isEmpty()) { + throw NoMatchingRootException.create(rootMatcher, roots); + } + + return reduceRoots(selectedRoots); + } + + @SuppressWarnings("unused") + private void waitForAtLeastOneActivityToBeResumed() { + Collection<Activity> resumedActivities = + activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + uiController.loopMainThreadUntilIdle(); + resumedActivities = activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + } + if (resumedActivities.isEmpty()) { + List<Activity> activities = Lists.newArrayList(); + for (Stage s : EnumSet.range(Stage.PRE_ON_CREATE, Stage.RESTARTED)) { + activities.addAll(activityLifecycleMonitor.getActivitiesInStage(s)); + } + if (activities.isEmpty()) { + throw new RuntimeException("No activities found. Did you forget to launch the activity " + + "by calling getActivity() or startActivitySync or similar?"); + } + // well at least there are some activities in the pipeline - lets see if they resume. + + long[] waitTimes = + {10, 50, 100, 500, TimeUnit.SECONDS.toMillis(2), TimeUnit.SECONDS.toMillis(30)}; + + for (int waitIdx = 0; waitIdx < waitTimes.length; waitIdx++) { + Log.w(TAG, "No activity currently resumed - waiting: " + waitTimes[waitIdx] + + "ms for one to appear."); + uiController.loopMainThreadForAtLeast(waitTimes[waitIdx]); + resumedActivities = activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (!resumedActivities.isEmpty()) { + return; // one of the pending activities has resumed + } + } + throw new NoActivityResumedException("No activities in stage RESUMED. Did you forget to " + + "launch the activity. (test.getActivity() or similar)?"); + } + } + + private Root reduceRoots(List<Root> subpanels) { + Root topSubpanel = subpanels.get(0); + if (subpanels.size() >= 1) { + for (Root subpanel : subpanels) { + if (isDialog().matches(subpanel)) { + return subpanel; + } + if (subpanel.getWindowLayoutParams().get().type + > topSubpanel.getWindowLayoutParams().get().type) { + topSubpanel = subpanel; + } + } + } + return topSubpanel; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java new file mode 100644 index 0000000..a284ede --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.common.collect.Lists; + +import android.os.Build; +import android.os.Looper; +import android.util.Log; +import android.view.View; +import android.view.WindowManager.LayoutParams; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +/** + * Provides access to all root views in an application. + * + * 95% of the time this is unnecessary and we can operate solely on current Activity's root view + * as indicated by getWindow().getDecorView(). However in the case of popup windows, menus, and + * dialogs the actual view hierarchy we should be operating on is not accessible thru public apis. + * + * In the spirit of degrading gracefully when new api levels break compatibility, callers should + * handle a list of size 0 by assuming getWindow().getDecorView() on the currently resumed activity + * is the sole root - this assumption will be correct often enough. + * + * Obviously, you need to be on the main thread to use this. + */ +@Singleton +final class RootsOracle implements Provider<List<Root>> { + + private static final String TAG = RootsOracle.class.getSimpleName(); + private static final String WINDOW_MANAGER_IMPL_CLAZZ = + "android.view.WindowManagerImpl"; + private static final String WINDOW_MANAGER_GLOBAL_CLAZZ = + "android.view.WindowManagerGlobal"; + private static final String VIEWS_FIELD = "mViews"; + private static final String WINDOW_PARAMS_FIELD = "mParams"; + private static final String GET_DEFAULT_IMPL = "getDefault"; + private static final String GET_GLOBAL_INSTANCE = "getInstance"; + + private final Looper mainLooper; + private boolean initialized; + private Object windowManagerObj; + private Field viewsField; + private Field paramsField; + + @Inject + RootsOracle(Looper mainLooper) { + this.mainLooper = mainLooper; + } + + @SuppressWarnings("unchecked") + @Override + public List<Root> get() { + checkState(mainLooper.equals(Looper.myLooper()), "must be called on main thread."); + + if (!initialized) { + initialize(); + } + + if (null == windowManagerObj) { + Log.w(TAG, "No reflective access to windowmanager object."); + return Lists.newArrayList(); + } + + if (null == viewsField) { + Log.w(TAG, "No reflective access to mViews"); + return Lists.newArrayList(); + } + if (null == paramsField) { + Log.w(TAG, "No reflective access to mPArams"); + return Lists.newArrayList(); + } + + List<View> views = null; + List<LayoutParams> params = null; + + try { + if (Build.VERSION.SDK_INT < 19) { + views = Arrays.asList((View[]) viewsField.get(windowManagerObj)); + params = Arrays.asList((LayoutParams[]) paramsField.get(windowManagerObj)); + } else { + views = (List<View>) viewsField.get(windowManagerObj); + params = (List<LayoutParams>) paramsField.get(windowManagerObj); + } + } catch (RuntimeException re) { + Log.w(TAG, String.format("Reflective access to %s or %s on %s failed.", + viewsField, paramsField, windowManagerObj), re); + return Lists.newArrayList(); + } catch (IllegalAccessException iae) { + Log.w(TAG, String.format("Reflective access to %s or %s on %s failed.", + viewsField, paramsField, windowManagerObj), iae); + return Lists.newArrayList(); + } + + + List<Root> roots = Lists.newArrayList(); + for (int i = views.size() - 1; i > -1; i--) { + roots.add( + new Root.Builder() + .withDecorView(views.get(i)) + .withWindowLayoutParams(params.get(i)) + .build()); + } + + return roots; + } + + private void initialize() { + initialized = true; + String accessClass = Build.VERSION.SDK_INT > 16 ? WINDOW_MANAGER_GLOBAL_CLAZZ + : WINDOW_MANAGER_IMPL_CLAZZ; + String instanceMethod = Build.VERSION.SDK_INT > 16 ? GET_GLOBAL_INSTANCE : GET_DEFAULT_IMPL; + + try { + Class<?> clazz = Class.forName(accessClass); + Method getMethod = clazz.getMethod(instanceMethod); + windowManagerObj = getMethod.invoke(null); + viewsField = clazz.getDeclaredField(VIEWS_FIELD); + viewsField.setAccessible(true); + paramsField = clazz.getDeclaredField(WINDOW_PARAMS_FIELD); + paramsField.setAccessible(true); + } catch (InvocationTargetException ite) { + Log.e(TAG, String.format("could not invoke: %s on %s", instanceMethod, accessClass), + ite.getCause()); + } catch (ClassNotFoundException cnfe) { + Log.e(TAG, String.format("could not find class: %s", accessClass), cnfe); + } catch (NoSuchFieldException nsfe) { + Log.e(TAG, String.format("could not find field: %s or %s on %s", WINDOW_PARAMS_FIELD, + VIEWS_FIELD, accessClass), nsfe); + } catch (NoSuchMethodException nsme) { + Log.e(TAG, String.format("could not find method: %s on %s", instanceMethod, accessClass), + nsme); + } catch (RuntimeException re) { + Log.e(TAG, String.format("reflective setup failed using obj: %s method: %s field: %s", + accessClass, instanceMethod, VIEWS_FIELD), re); + } catch (IllegalAccessException iae) { + Log.e(TAG, String.format("reflective setup failed using obj: %s method: %s field: %s", + accessClass, instanceMethod, VIEWS_FIELD), iae); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java new file mode 100644 index 0000000..b28255e --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import javax.inject.Qualifier; + +/** + * Annotates a AsyncTaskMonitor as monitoring the SdkAsyncTask pool + */ +@Qualifier +@Retention(RetentionPolicy.RUNTIME) +@interface SdkAsyncTask { } diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java new file mode 100644 index 0000000..1a719a3 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import com.google.common.base.Optional; + +import android.os.Build; +import android.os.Handler; +import android.os.Looper; + +import java.lang.reflect.Field; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.ThreadPoolExecutor; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Extracts ThreadPoolExecutors used by pieces of android. + * + * We do some work to ensure that we load the classes containing these thread pools + * on the main thread, since they may have static initialization that assumes access + * to the main looper. + */ +@Singleton +final class ThreadPoolExecutorExtractor { + private static final String ASYNC_TASK_CLASS_NAME = "android.os.AsyncTask"; + private static final String MODERN_ASYNC_TASK_CLASS_NAME = + "android.support.v4.content.ModernAsyncTask"; + private static final String MODERN_ASYNC_TASK_FIELD_NAME = "THREAD_POOL_EXECUTOR"; + private static final String LEGACY_ASYNC_TASK_FIELD_NAME = "sExecutor"; + private final Handler mainHandler; + + @Inject + ThreadPoolExecutorExtractor(Looper looper) { + mainHandler = new Handler(looper); + } + + + public ThreadPoolExecutor getAsyncTaskThreadPool() { + FutureTask<Optional<ThreadPoolExecutor>> getTask = null; + if (Build.VERSION.SDK_INT < 11) { + getTask = new FutureTask<Optional<ThreadPoolExecutor>>(LEGACY_ASYNC_TASK_EXECUTOR); + } else { + getTask = new FutureTask<Optional<ThreadPoolExecutor>>(POST_HONEYCOMB_ASYNC_TASK_EXECUTOR); + } + + try { + return runOnMainThread(getTask).get().get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted while trying to get the async task executor!", ie); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + } + + public Optional<ThreadPoolExecutor> getCompatAsyncTaskThreadPool() { + try { + return runOnMainThread( + new FutureTask<Optional<ThreadPoolExecutor>>(MODERN_ASYNC_TASK_EXTRACTOR)).get(); + } catch (InterruptedException ie) { + throw new RuntimeException("Interrupted while trying to get the compat async executor!", ie); + } catch (ExecutionException ee) { + throw new RuntimeException(ee.getCause()); + } + } + + private <T> FutureTask<T> runOnMainThread(final FutureTask<T> futureToRun) { + if (Looper.myLooper() != Looper.getMainLooper()) { + final CountDownLatch latch = new CountDownLatch(1); + mainHandler.post(new Runnable() { + @Override + public void run() { + try { + futureToRun.run(); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(); + } catch (InterruptedException ie) { + if (!futureToRun.isDone()) { + throw new RuntimeException("Interrupted while waiting for task to complete."); + } + } + } else { + futureToRun.run(); + } + + return futureToRun; + } + + private static final Callable<Optional<ThreadPoolExecutor>> MODERN_ASYNC_TASK_EXTRACTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + try { + Class<?> modernClazz = Class.forName(MODERN_ASYNC_TASK_CLASS_NAME); + Field executorField = modernClazz.getField(MODERN_ASYNC_TASK_FIELD_NAME); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } catch (ClassNotFoundException cnfe) { + return Optional.<ThreadPoolExecutor>absent(); + } + } + }; + + private static final Callable<Class<?>> LOAD_ASYNC_TASK_CLASS = + new Callable<Class<?>>() { + @Override + public Class<?> call() throws Exception { + return Class.forName(ASYNC_TASK_CLASS_NAME); + } + }; + + private static final Callable<Optional<ThreadPoolExecutor>> LEGACY_ASYNC_TASK_EXECUTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + Field executorField = LOAD_ASYNC_TASK_CLASS.call() + .getDeclaredField(LEGACY_ASYNC_TASK_FIELD_NAME); + executorField.setAccessible(true); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } + }; + + private static final Callable<Optional<ThreadPoolExecutor>> POST_HONEYCOMB_ASYNC_TASK_EXECUTOR = + new Callable<Optional<ThreadPoolExecutor>>() { + @Override + public Optional<ThreadPoolExecutor> call() throws Exception { + Field executorField = LOAD_ASYNC_TASK_CLASS.call() + .getField(MODERN_ASYNC_TASK_FIELD_NAME); + return Optional.of((ThreadPoolExecutor) executorField.get(null)); + } + }; +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java new file mode 100644 index 0000000..c1aaa5c --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java @@ -0,0 +1,535 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicies; +import com.google.android.apps.common.testing.ui.espresso.IdlingPolicy; +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; +import com.google.android.apps.common.testing.ui.espresso.UiController; +import com.google.android.apps.common.testing.ui.espresso.base.IdlingResourceRegistry.IdleNotificationCallback; +import com.google.android.apps.common.testing.ui.espresso.base.QueueInterrogator.QueueState; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Optional; +import com.google.common.collect.Lists; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.SystemClock; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.util.BitSet; +import java.util.EnumSet; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.FutureTask; + +import javax.inject.Inject; +import javax.inject.Singleton; + +/** + * Implementation of {@link UiController}. + */ +@Singleton +final class UiControllerImpl implements UiController, Handler.Callback { + + private static final String TAG = UiControllerImpl.class.getSimpleName(); + + private static final Callable<Void> NO_OP = new Callable<Void>() { + @Override + public Void call() { + return null; + } + }; + + /** + * Responsible for signaling a particular condition is met / verifying that signal. + */ + enum IdleCondition { + DELAY_HAS_PAST, + ASYNC_TASKS_HAVE_IDLED, + COMPAT_TASKS_HAVE_IDLED, + KEY_INJECT_HAS_COMPLETED, + MOTION_INJECTION_HAS_COMPLETED, + DYNAMIC_TASKS_HAVE_IDLED; + + /** + * Checks whether this condition has been signaled. + */ + public boolean isSignaled(BitSet conditionSet) { + return conditionSet.get(ordinal()); + } + + /** + * Resets the signal state for this condition. + */ + public void reset(BitSet conditionSet) { + conditionSet.set(ordinal(), false); + } + + /** + * Creates a message that when sent will raise the signal of this condition. + */ + public Message createSignal(Handler handler, int myGeneration) { + return Message.obtain(handler, ordinal(), myGeneration, 0, null); + } + + /** + * Handles a message that is raising a signal and updates the condition set accordingly. + * Messages from a previous generation will be ignored. + */ + public static boolean handleMessage(Message message, BitSet conditionSet, + int currentGeneration) { + IdleCondition [] allConditions = values(); + if (message.what < 0 || message.what >= allConditions.length) { + return false; + } else { + IdleCondition condition = allConditions[message.what]; + if (message.arg1 == currentGeneration) { + condition.signal(conditionSet); + } else { + Log.w(TAG, "ignoring signal of: " + condition + " from previous generation: " + + message.arg1 + " current generation: " + currentGeneration); + } + return true; + } + } + + public static BitSet createConditionSet() { + return new BitSet(values().length); + } + + /** + * Requests that the given bitset be updated to indicate that this condition has been + * signaled. + */ + protected void signal(BitSet conditionSet) { + conditionSet.set(ordinal()); + } + } + + private final EventInjector eventInjector; + private final BitSet conditionSet; + private final AsyncTaskPoolMonitor asyncTaskMonitor; + private final Optional<AsyncTaskPoolMonitor> compatTaskMonitor; + private final IdlingResourceRegistry idlingResourceRegistry; + private final ExecutorService keyEventExecutor = Executors.newSingleThreadExecutor(); + private final QueueInterrogator queueInterrogator; + private final Looper mainLooper; + + private Handler controllerHandler; + // only updated on main thread. + private boolean looping = false; + private int generation = 0; + + @VisibleForTesting + @Inject + UiControllerImpl(EventInjector eventInjector, + @SdkAsyncTask AsyncTaskPoolMonitor asyncTaskMonitor, + @CompatAsyncTask Optional<AsyncTaskPoolMonitor> compatTaskMonitor, + IdlingResourceRegistry registry, + Looper mainLooper) { + this.eventInjector = checkNotNull(eventInjector); + this.asyncTaskMonitor = checkNotNull(asyncTaskMonitor); + this.compatTaskMonitor = checkNotNull(compatTaskMonitor); + this.conditionSet = IdleCondition.createConditionSet(); + this.idlingResourceRegistry = checkNotNull(registry); + this.mainLooper = checkNotNull(mainLooper); + this.queueInterrogator = new QueueInterrogator(mainLooper); + } + + @SuppressWarnings("deprecation") + @Override + public boolean injectKeyEvent(final KeyEvent event) throws InjectEventSecurityException { + checkNotNull(event); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + loopMainThreadUntilIdle(); + + FutureTask<Boolean> injectTask = new SignalingTask<Boolean>( + new Callable<Boolean>() { + @Override + public Boolean call() throws Exception { + return eventInjector.injectKeyEvent(event); + } + }, + IdleCondition.KEY_INJECT_HAS_COMPLETED, + generation); + + // Inject the key event. + keyEventExecutor.submit(injectTask); + + loopUntil(IdleCondition.KEY_INJECT_HAS_COMPLETED); + + try { + checkState(injectTask.isDone(), "Key injection was signaled - but it wasnt done."); + return injectTask.get(); + } catch (ExecutionException ee) { + if (ee.getCause() instanceof InjectEventSecurityException) { + throw (InjectEventSecurityException) ee.getCause(); + } else { + throw new RuntimeException(ee.getCause()); + } + } catch (InterruptedException neverHappens) { + // we only call get() after done() is signaled. + // we should never block. + throw new RuntimeException("impossible.", neverHappens); + } + } + + @Override + public boolean injectMotionEvent(final MotionEvent event) throws InjectEventSecurityException { + checkNotNull(event); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + + FutureTask<Boolean> injectTask = new SignalingTask<Boolean>( + new Callable<Boolean>() { + @Override + public Boolean call() throws Exception { + return eventInjector.injectMotionEvent(event); + } + }, + IdleCondition.MOTION_INJECTION_HAS_COMPLETED, + generation); + keyEventExecutor.submit(injectTask); + loopUntil(IdleCondition.MOTION_INJECTION_HAS_COMPLETED); + try { + checkState(injectTask.isDone(), "Key injection was signaled - but it wasnt done."); + return injectTask.get(); + } catch (ExecutionException ee) { + if (ee.getCause() instanceof InjectEventSecurityException) { + throw (InjectEventSecurityException) ee.getCause(); + } else { + throw propagate(ee.getCause() != null ? ee.getCause() : ee); + } + } catch (InterruptedException neverHappens) { + // we only call get() after done() is signaled. + // we should never block. + throw propagate(neverHappens); + } finally { + loopMainThreadUntilIdle(); + } + } + + @Override + public boolean injectString(String str) throws InjectEventSecurityException { + checkNotNull(str); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + initialize(); + + // No-op if string is empty. + if (str.length() == 0) { + Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed)."); + return true; + } + + boolean eventInjected = false; + KeyCharacterMap keyCharacterMap = getKeyCharacterMap(); + + // TODO(user): Investigate why not use (as suggested in javadoc of keyCharacterMap.getEvents): + // http://developer.android.com/reference/android/view/KeyEvent.html#KeyEvent(long, + // java.lang.String, int, int) + KeyEvent[] events = keyCharacterMap.getEvents(str.toCharArray()); + checkNotNull(events, "Failed to get events for string " + str); + Log.d(TAG, String.format("Injecting string: \"%s\"", str)); + + for (KeyEvent event : events) { + checkNotNull(event, String.format("Failed to get event for character (%c) with key code (%s)", + event.getKeyCode(), event.getUnicodeChar())); + + eventInjected = false; + for (int attempts = 0; !eventInjected && attempts < 4; attempts++) { + attempts++; + + // We have to change the time of an event before injecting it because + // all KeyEvents returned by KeyCharacterMap.getEvents() have the same + // time stamp and the system rejects too old events. Hence, it is + // possible for an event to become stale before it is injected if it + // takes too long to inject the preceding ones. + event = KeyEvent.changeTimeRepeat(event, SystemClock.uptimeMillis(), 0); + eventInjected = injectKeyEvent(event); + } + + if (!eventInjected) { + Log.e(TAG, String.format("Failed to inject event for character (%c) with key code (%s)", + event.getUnicodeChar(), event.getKeyCode())); + break; + } + } + + return eventInjected; + } + + @SuppressLint("InlinedApi") + @VisibleForTesting + @SuppressWarnings("deprecation") + public static KeyCharacterMap getKeyCharacterMap() { + KeyCharacterMap keyCharacterMap = null; + + // KeyCharacterMap.VIRTUAL_KEYBOARD is present from API11. + // For earlier APIs we use KeyCharacterMap.BUILT_IN_KEYBOARD + if (Build.VERSION.SDK_INT < 11) { + keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.BUILT_IN_KEYBOARD); + } else { + keyCharacterMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + return keyCharacterMap; + } + + + @Override + public void loopMainThreadUntilIdle() { + initialize(); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + do { + EnumSet<IdleCondition> condChecks = EnumSet.noneOf(IdleCondition.class); + if (!asyncTaskMonitor.isIdleNow()) { + asyncTaskMonitor.notifyWhenIdle(new SignalingTask<Void>(NO_OP, + IdleCondition.ASYNC_TASKS_HAVE_IDLED, generation)); + + condChecks.add(IdleCondition.ASYNC_TASKS_HAVE_IDLED); + } + + if (!compatIdle()) { + compatTaskMonitor.get().notifyWhenIdle(new SignalingTask<Void>(NO_OP, + IdleCondition.COMPAT_TASKS_HAVE_IDLED, generation)); + condChecks.add(IdleCondition.COMPAT_TASKS_HAVE_IDLED); + } + + if (!idlingResourceRegistry.allResourcesAreIdle()) { + final IdlingPolicy warning = IdlingPolicies.getDynamicIdlingResourceWarningPolicy(); + final IdlingPolicy error = IdlingPolicies.getDynamicIdlingResourceErrorPolicy(); + final SignalingTask<Void> idleSignal = new SignalingTask<Void>(NO_OP, + IdleCondition.DYNAMIC_TASKS_HAVE_IDLED, generation); + idlingResourceRegistry.notifyWhenAllResourcesAreIdle(new IdleNotificationCallback() { + @Override + public void resourcesStillBusyWarning(List<String> busyResourceNames) { + warning.handleTimeout(busyResourceNames, "IdlingResources are still busy!"); + } + + @Override + public void resourcesHaveTimedOut(List<String> busyResourceNames) { + error.handleTimeout(busyResourceNames, "IdlingResources have timed out!"); + controllerHandler.post(idleSignal); + } + + @Override + public void allResourcesIdle() { + controllerHandler.post(idleSignal); + } + }); + condChecks.add(IdleCondition.DYNAMIC_TASKS_HAVE_IDLED); + } + + try { + loopUntil(condChecks); + } finally { + asyncTaskMonitor.cancelIdleMonitor(); + if (compatTaskMonitor.isPresent()) { + compatTaskMonitor.get().cancelIdleMonitor(); + } + idlingResourceRegistry.cancelIdleMonitor(); + } + } while (!asyncTaskMonitor.isIdleNow() || !compatIdle() + || !idlingResourceRegistry.allResourcesAreIdle()); + + } + + private boolean compatIdle() { + if (compatTaskMonitor.isPresent()) { + return compatTaskMonitor.get().isIdleNow(); + } else { + return true; + } + } + + @Override + public void loopMainThreadForAtLeast(long millisDelay) { + initialize(); + checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!"); + checkState(!IdleCondition.DELAY_HAS_PAST.isSignaled(conditionSet), "recursion detected!"); + + checkArgument(millisDelay > 0); + controllerHandler.postDelayed(new SignalingTask(NO_OP, IdleCondition.DELAY_HAS_PAST, + generation), + millisDelay); + loopUntil(IdleCondition.DELAY_HAS_PAST); + loopMainThreadUntilIdle(); + } + + @Override + public boolean handleMessage(Message msg) { + if (!IdleCondition.handleMessage(msg, conditionSet, generation)) { + Log.i(TAG, "Unknown message type: " + msg); + return false; + } else { + return true; + } + } + + private void loopUntil(IdleCondition condition) { + loopUntil(EnumSet.of(condition)); + } + + /** + * Loops the main thread until all IdleConditions have been signaled. + * + * Once they've been signaled, the conditions are reset and the generation value + * is incremented. + * + * Signals should only be raised thru SignalingTask instances, and care should be + * taken to ensure that the signaling task is created before loopUntil is called. + * + * Good: + * idlingType.runOnIdle(new SignalingTask(NO_OP, IdleCondition.MY_IDLE_CONDITION, generation)); + * loopUntil(IdleCondition.MY_IDLE_CONDITION); + * + * Bad: + * idlingType.runOnIdle(new CustomCallback() { + * @Override + * public void itsDone() { + * // oh no - The creation of this signaling task is delayed until this method is + * // called, so it will not have the right value for generation. + * new SignalingTask(NO_OP, IdleCondition.MY_IDLE_CONDITION, generation).run(); + * } + * }) + * loopUntil(IdleCondition.MY_IDLE_CONDITION); + */ + private void loopUntil(EnumSet<IdleCondition> conditions) { + checkState(!looping, "Recursive looping detected!"); + looping = true; + IdlingPolicy masterIdlePolicy = IdlingPolicies.getMasterIdlingPolicy(); + try { + int loopCount = 0; + long start = SystemClock.uptimeMillis(); + long end = start + masterIdlePolicy.getIdleTimeoutUnit().toMillis( + masterIdlePolicy.getIdleTimeout()); + while (SystemClock.uptimeMillis() < end) { + boolean conditionsMet = true; + boolean shouldLogConditionState = loopCount > 0 && loopCount % 100 == 0; + + for (IdleCondition condition : conditions) { + if (!condition.isSignaled(conditionSet)) { + conditionsMet = false; + if (shouldLogConditionState) { + Log.w(TAG, "Waiting for: " + condition.name() + " for " + loopCount + " iterations."); + } else { + break; + } + } + } + + if (conditionsMet) { + QueueState queueState = queueInterrogator.determineQueueState(); + if (queueState == QueueState.EMPTY || queueState == QueueState.TASK_DUE_LONG) { + return; + } else { + Log.v( + "ESP_TRACE", + + "Barrier detected or task avaliable for running shortly."); + } + } + + Message message = queueInterrogator.getNextMessage(); + String callbackString = "unknown"; + String messageString = "unknown"; + try { + if (null == message.getCallback()) { + callbackString = "no callback."; + } else { + callbackString = message.getCallback().toString(); + } + messageString = message.toString(); + } catch (NullPointerException e) { + /* + * Ignore. android.app.ActivityThread$ActivityClientRecord#toString() fails for API level + * 15. + */ + } + + Log.v( + "ESP_TRACE", + String.format("%s: MessageQueue.next(): %s, with target: %s, callback: %s", TAG, + messageString, message.getTarget().getClass().getCanonicalName(), callbackString)); + message.getTarget().dispatchMessage(message); + message.recycle(); + loopCount++; + } + List<String> idleConditions = Lists.newArrayList(); + for (IdleCondition condition : conditions) { + if (!condition.isSignaled(conditionSet)) { + idleConditions.add(condition.name()); + } + } + masterIdlePolicy.handleTimeout(idleConditions, String.format( + "Looped for %s iterations over %s %s.", loopCount, masterIdlePolicy.getIdleTimeout(), + masterIdlePolicy.getIdleTimeoutUnit().name())); + } finally { + looping = false; + generation++; + for (IdleCondition condition : conditions) { + condition.reset(conditionSet); + } + } + } + + + private void initialize() { + if (controllerHandler == null) { + controllerHandler = new Handler(this); + } + } + + + /** + * Encapsulates posting a signal message to update the conditions set after a task has + * executed. + */ + private class SignalingTask<T> extends FutureTask<T> { + + private final IdleCondition condition; + private final int myGeneration; + + public SignalingTask(Callable<T> callable, IdleCondition condition, int myGeneration) { + super(callable); + this.condition = checkNotNull(condition); + this.myGeneration = myGeneration; + } + + @Override + protected void done() { + controllerHandler.sendMessage(condition.createSignal(controllerHandler, myGeneration)); + } + + } + +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java new file mode 100644 index 0000000..30e0658 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.AmbiguousViewMatcherException; +import com.google.android.apps.common.testing.ui.espresso.NoMatchingViewException; +import com.google.android.apps.common.testing.ui.espresso.ViewFinder; +import com.google.android.apps.common.testing.ui.espresso.matcher.ViewMatchers; +import com.google.common.base.Joiner; +import com.google.common.base.Optional; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; +import com.google.common.collect.Iterators; +import com.google.common.collect.Lists; + +import android.os.Looper; +import android.view.View; +import android.widget.AdapterView; + +import org.hamcrest.Matcher; + +import java.util.Iterator; +import java.util.List; + +import javax.inject.Inject; +import javax.inject.Provider; + +/** + * Implementation of {@link ViewFinder}. + */ +// TODO(user): in the future we may want to collect stats here about the size of the view +// hierarchy, average matcher execution time, warn when matchers take too long to execute, etc. +public final class ViewFinderImpl implements ViewFinder { + + private final Matcher<View> viewMatcher; + private final Provider<View> rootViewProvider; + + @Inject + ViewFinderImpl(Matcher<View> viewMatcher, Provider<View> rootViewProvider) { + this.viewMatcher = viewMatcher; + this.rootViewProvider = rootViewProvider; + } + + @Override + public View getView() throws AmbiguousViewMatcherException, NoMatchingViewException { + checkMainThread(); + final Predicate<View> matcherPredicate = new MatcherPredicateAdapter<View>( + checkNotNull(viewMatcher)); + + View root = rootViewProvider.get(); + Iterator<View> matchedViewIterator = Iterables.filter( + breadthFirstViewTraversal(root), + matcherPredicate).iterator(); + + View matchedView = null; + + while (matchedViewIterator.hasNext()) { + if (matchedView != null) { + // Ambiguous! + throw new AmbiguousViewMatcherException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .withView1(matchedView) + .withView2(matchedViewIterator.next()) + .withOtherAmbiguousViews(Iterators.toArray(matchedViewIterator, View.class)) + .build(); + } else { + matchedView = matchedViewIterator.next(); + } + } + if (null == matchedView) { + final Predicate<View> adapterViewPredicate = new MatcherPredicateAdapter<View>( + ViewMatchers.isAssignableFrom(AdapterView.class)); + List<View> adapterViews = Lists.newArrayList( + Iterables.filter(breadthFirstViewTraversal(root), adapterViewPredicate).iterator()); + if (adapterViews.isEmpty()) { + throw new NoMatchingViewException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .build(); + } + + String warning = String.format("\nIf the target view is not part of the view hierarchy, you " + + "may need to use Espresso.onData to load it from one of the following AdapterViews:%s" + , Joiner.on("\n- ").join(adapterViews)); + throw new NoMatchingViewException.Builder() + .withViewMatcher(viewMatcher) + .withRootView(root) + .withAdapterViews(adapterViews) + .withAdapterViewWarning(Optional.of(warning)) + .build(); + } else { + return matchedView; + } + } + + private void checkMainThread() { + checkState(Thread.currentThread().equals(Looper.getMainLooper().getThread()), + "Executing a query on the view hierarchy outside of the main thread (on: %s)", + Thread.currentThread().getName()); + } + + private static class MatcherPredicateAdapter<T> implements Predicate<T> { + private final Matcher<? super T> matcher; + + private MatcherPredicateAdapter(Matcher<? super T> matcher) { + this.matcher = checkNotNull(matcher); + } + + @Override + public boolean apply(T input) { + return matcher.matches(input); + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java new file mode 100644 index 0000000..05792e7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.base; + +import static com.google.common.base.Preconditions.checkState; +import static com.google.common.base.Throwables.propagate; + +import com.google.android.apps.common.testing.ui.espresso.InjectEventSecurityException; + +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * An {@link EventInjectionStrategy} that uses the window manager to inject {@link MotionEvent}s. + * This strategy supports API level 15 and below. + */ +final class WindowManagerEventInjectionStrategy implements EventInjectionStrategy { + private static final String TAG = WindowManagerEventInjectionStrategy.class.getSimpleName(); + + + WindowManagerEventInjectionStrategy() { + checkState(Build.VERSION.SDK_INT >= 7 && Build.VERSION.SDK_INT <= 15, "Unsupported API level."); + } + + // Reflection members. + private boolean initComplete; + private Object wmInstance; + private Method injectInputKeyEventMethod; + private Method injectInputMotionEventMethod; + + void initialize() { + if (initComplete) { + return; + } + + try { + Log.d(TAG, "Trying to create injection strategy."); + + Class<?> serviceManagerClassObj = Class.forName("android.os.ServiceManager"); + Method windowServiceMethod = + serviceManagerClassObj.getDeclaredMethod("getService", String.class); + windowServiceMethod.setAccessible(true); + + Object windowServiceBinderObj = windowServiceMethod.invoke(serviceManagerClassObj, "window"); + + Class<?> windowManagerStubObject = Class.forName("android.view.IWindowManager$Stub"); + Method asInterfaceMethod = + windowManagerStubObject.getDeclaredMethod("asInterface", IBinder.class); + asInterfaceMethod.setAccessible(true); + + wmInstance = asInterfaceMethod.invoke(windowManagerStubObject, windowServiceBinderObj); + + injectInputMotionEventMethod = wmInstance.getClass() + .getDeclaredMethod("injectPointerEvent", MotionEvent.class, Boolean.TYPE); + injectInputMotionEventMethod.setAccessible(true); + + injectInputKeyEventMethod = + wmInstance.getClass().getDeclaredMethod("injectKeyEvent", KeyEvent.class, Boolean.TYPE); + injectInputMotionEventMethod.setAccessible(true); + + initComplete = true; + } catch (ClassNotFoundException e) { + propagate(e); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + propagate(e); + } catch (NoSuchMethodException e) { + propagate(e); + } catch (SecurityException e) { + propagate(e); + } + } + + @Override + public boolean injectKeyEvent(KeyEvent keyEvent) throws InjectEventSecurityException { + try { + // From javadoc of com.android.server.WindowManagerService.injectKeyEvent: + // @param sync If true, wait for the event to be completed before returning to the caller. + // @return true if event was dispatched, false if it was dropped for any reason + // + // Key events are delivered OFF the main thread, and we block until they are processed. + return (Boolean) injectInputKeyEventMethod.invoke(wmInstance, keyEvent, true); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } + + @Override + public boolean injectMotionEvent(MotionEvent motionEvent) throws InjectEventSecurityException { + try { + // From javadoc of com.android.server.WindowManagerService.injectKeyEvent: + // @param sync If true, wait for the event to be completed before returning to the caller. + // @return true if event was dispatched, false if it was dropped for any reason + // + // We inject the pointer with sync=true to ensure the event is dispatched before control + // is returned to our code. + return (Boolean) injectInputMotionEventMethod.invoke( + wmInstance, + motionEvent, + true /* sync */); + } catch (IllegalAccessException e) { + propagate(e); + } catch (IllegalArgumentException e) { + propagate(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof SecurityException) { + throw new InjectEventSecurityException(cause); + } + propagate(e); + } catch (SecurityException e) { + throw new InjectEventSecurityException(e); + } + return false; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java new file mode 100644 index 0000000..c67f199 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java @@ -0,0 +1,210 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.contrib; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; + +import com.google.android.apps.common.testing.ui.espresso.IdlingResource; + +import android.os.SystemClock; +import android.util.Log; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * An implementation of {@link IdlingResource} that determines idleness by maintaining an internal + * counter. When the counter is 0 - it is considered to be idle, when it is non-zero it is not + * idle. This is very similar to the way a {@link java.util.concurrent.Semaphore} behaves. + * <p> + * The counter may be incremented or decremented from any thread. If it reaches an illogical state + * (like counter less than zero) it will throw an IllegalStateException. + * </p> + * <p> + * This class can then be used to wrap up operations that while in progress should block tests from + * accessing the UI. + * </p> + * + * <pre> + * {@code + * public interface FooServer { + * public Foo newFoo(); + * public void updateFoo(Foo foo); + * } + * + * public DecoratedFooServer implements FooServer { + * private final FooServer realFooServer; + * private final CountingIdlingResource fooServerIdlingResource; + * + * public DecoratedFooServer(FooServer realFooServer, + * CountingIdlingResource fooServerIdlingResource) { + * this.realFooServer = checkNotNull(realFooServer); + * this.fooServerIdlingResource = checkNotNull(fooServerIdlingResource); + * } + * + * public Foo newFoo() { + * fooServerIdlingResource.increment(); + * try { + * return realFooServer.newFoo(); + * } finally { + * fooServerIdlingResource.decrement(); + * } + * } + * + * public void updateFoo(Foo foo) { + * fooServerIdlingResource.increment(); + * try { + * realFooServer.updateFoo(foo); + * } finally { + * fooServerIdlingResource.decrement(); + * } + * } + * } + * } + * </pre> + * + * Then in your test setup: + * <pre> + * {@code + * public void setUp() throws Exception { + * super.setUp(); + * FooServer realServer = FooApplication.getFooServer(); + * CountingIdlingResource countingResource = new CountingIdlingResource("FooServerCalls"); + * FooApplication.setFooServer(new DecoratedFooServer(realServer, countingResource)); + * Espresso.registerIdlingResource(countingResource); + * } + * } + * </pre> + * + */ +@SuppressWarnings("javadoc") +public final class CountingIdlingResource implements IdlingResource { + private static final String TAG = "CountingIdlingResource"; + private final String resourceName; + private final AtomicInteger counter = new AtomicInteger(0); + private final boolean debugCounting; + + // written from main thread, read from any thread. + private volatile ResourceCallback resourceCallback; + + // read/written from any thread - used for debugging messages. + private volatile long becameBusyAt = 0; + private volatile long becameIdleAt = 0; + + /** + * Creates a CountingIdlingResource without debug tracing. + * + * @param resourceName the resource name this resource should report to Espresso. + */ + public CountingIdlingResource(String resourceName) { + this(resourceName, false); + } + + /** + * Creates a CountingIdlingResource. + * + * @param resourceName the resource name this resource should report to Espresso. + * @param debugCounting if true increment & decrement calls will print trace information to logs. + */ + public CountingIdlingResource(String resourceName, boolean debugCounting) { + this.resourceName = checkNotNull(resourceName); + this.debugCounting = debugCounting; + } + + @Override + public String getName() { + return resourceName; + } + + @Override + public boolean isIdleNow() { + return counter.get() == 0; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback resourceCallback) { + this.resourceCallback = resourceCallback; + } + + /** + * Increments the count of in-flight transactions to the resource being monitored. + * + * This method can be called from any thread. + */ + public void increment() { + int counterVal = counter.getAndIncrement(); + if (0 == counterVal) { + becameBusyAt = SystemClock.uptimeMillis(); + } + + if (debugCounting) { + Log.i(TAG, "Resource: " + resourceName + " in-use-count incremented to: " + (counterVal + 1)); + } + } + + /** + * Decrements the count of in-flight transactions to the resource being monitored. + * + * If this operation results in the counter falling below 0 - an exception is raised. + * + * @throws IllegalStateException if the counter is below 0. + */ + public void decrement() { + int counterVal = counter.decrementAndGet(); + + if (counterVal == 0) { + // we've gone from non-zero to zero. That means we're idle now! Tell espresso. + if (null != resourceCallback) { + resourceCallback.onTransitionToIdle(); + } + becameIdleAt = SystemClock.uptimeMillis(); + } + + if (debugCounting) { + if (counterVal == 0) { + Log.i(TAG, "Resource: " + resourceName + " went idle! (Time spent not idle: " + + (becameIdleAt - becameBusyAt) + ")"); + } else { + Log.i(TAG, "Resource: " + resourceName + " in-use-count decremented to: " + counterVal); + } + } + checkState(counterVal > -1, "Counter has been corrupted!"); + } + + /** + * Prints the current state of this resource to the logcat at info level. + */ + public void dumpStateToLogs() { + StringBuilder message = new StringBuilder("Resource: ") + .append(resourceName) + .append(" inflight transaction count: ") + .append(counter.get()); + if (0 == becameBusyAt) { + Log.i(TAG, message.append(" and has never been busy!").toString()); + } else { + message.append(" and was last busy at: ") + .append(becameBusyAt); + if (0 == becameIdleAt) { + Log.w(TAG, message.append(" AND NEVER WENT IDLE!").toString()); + } else { + message.append(" and last went idle at: ") + .append(becameIdleAt); + Log.i(TAG, message.toString()); + } + } + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java new file mode 100644 index 0000000..55e8dde --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import org.hamcrest.BaseMatcher; + +/** + * Some matcher sugar that lets you create a matcher for a given type + * but only process items of a specific subtype of that matcher. + * + * @param <T> The desired type of the Matcher. + * @param <S> the subtype of T that your matcher applies safely to. + */ +public abstract class BoundedMatcher<T, S extends T> extends BaseMatcher<T> { + + private final Class<?> expectedType; + private final Class<?>[] interfaceTypes; + + public BoundedMatcher(Class<? extends S> expectedType) { + this.expectedType = checkNotNull(expectedType); + this.interfaceTypes = new Class[0]; + } + + public BoundedMatcher(Class<?> expectedType, Class<?> interfaceType1, + Class<?>... otherInterfaces) { + this.expectedType = checkNotNull(expectedType); + checkNotNull(otherInterfaces); + int interfaceCount = otherInterfaces.length + 1; + this.interfaceTypes = new Class[interfaceCount]; + + interfaceTypes[0] = checkNotNull(interfaceType1); + checkArgument(interfaceType1.isInterface()); + int interfaceTypeIdx = 1; + for (Class<?> intfType : otherInterfaces) { + interfaceTypes[interfaceTypeIdx] = checkNotNull(intfType); + checkArgument(intfType.isInterface()); + interfaceTypeIdx++; + } + } + + protected abstract boolean matchesSafely(S item); + + @Override + @SuppressWarnings({"unchecked"}) + public final boolean matches(Object item) { + if (item == null) { + return false; + } + + if (expectedType.isInstance(item)) { + for (Class<?> intfType : interfaceTypes) { + if (!intfType.isInstance(item)) { + return false; + } + } + return matchesSafely((S) item); + } + return false; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java new file mode 100644 index 0000000..13b6506 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.matcher; + +import static org.hamcrest.Matchers.is; + +import android.content.res.Resources; +import android.preference.Preference; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +/** + * A collection of hamcrest matchers that match {@link Preference}s. + */ +public final class PreferenceMatchers { + + private PreferenceMatchers() {} + + public static Matcher<Preference> withSummary(final int resourceId) { + return new TypeSafeMatcher<Preference>() { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText(" with summary string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: " ); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(Preference preference) { + if (null == expectedText) { + try { + expectedText = preference.getContext().getResources().getString(resourceId); + resourceName = preference.getContext().getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(preference.getSummary().toString()); + } else { + return false; + } + } + }; + } + + public static Matcher<Preference> withSummaryText(String summary) { + return withSummaryText(is(summary)); + } + + public static Matcher<Preference> withSummaryText(final Matcher<String> summaryMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" a preference with summary matching: "); + summaryMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + String summary = pref.getSummary().toString(); + return summaryMatcher.matches(summary); + } + }; + } + + public static Matcher<Preference> withTitle(final int resourceId) { + return new TypeSafeMatcher<Preference>() { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText(" with title string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: " ); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(Preference preference) { + if (null == expectedText) { + try { + expectedText = preference.getContext().getResources().getString(resourceId); + resourceName = preference.getContext().getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(preference.getTitle().toString()); + } else { + return false; + } + } + }; + } + + public static Matcher<Preference> withTitleText(String title) { + return withTitleText(is(title)); + } + + public static Matcher<Preference> withTitleText(final Matcher<String> titleMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" a preference with title matching: "); + titleMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + String title = pref.getTitle().toString(); + return titleMatcher.matches(title); + } + }; + } + + public static Matcher<Preference> isEnabled() { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" is an enabled preference"); + } + + @Override + public boolean matchesSafely(Preference pref) { + return pref.isEnabled(); + } + }; + } + + public static Matcher<Preference> withKey(String key) { + return withKey(is(key)); + } + + public static Matcher<Preference> withKey(final Matcher<String> keyMatcher) { + return new TypeSafeMatcher<Preference>() { + @Override + public void describeTo(Description description) { + description.appendText(" preference with key matching: "); + keyMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Preference pref) { + return keyMatcher.matches(pref.getKey()); + } + }; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java new file mode 100644 index 0000000..03be6c7 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.common.base.Preconditions.checkNotNull; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; + +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitor; +import com.google.android.apps.common.testing.testrunner.ActivityLifecycleMonitorRegistry; +import com.google.android.apps.common.testing.testrunner.Stage; +import com.google.android.apps.common.testing.ui.espresso.NoActivityResumedException; +import com.google.android.apps.common.testing.ui.espresso.Root; +import com.google.common.collect.Lists; + +import android.app.Activity; +import android.os.IBinder; +import android.view.View; +import android.view.WindowManager; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Collection; +import java.util.List; + +/** + * A collection of matchers for {@link Root} objects. + */ +public final class RootMatchers { + + private RootMatchers() {} + + /** + * Espresso's default {@link Root} matcher. + */ + @SuppressWarnings("unchecked") + public static final Matcher<Root> DEFAULT = + allOf( + hasWindowLayoutParams(), + allOf( + anyOf( + allOf(isDialog(), withDecorView(hasWindowFocus())), + isSubwindowOfCurrentActivity()), + isFocusable())); + + + /** + * Matches {@link Root}s that can take window focus. + */ + public static Matcher<Root> isFocusable() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is focusable"); + } + + @Override + public boolean matchesSafely(Root root) { + int flags = root.getWindowLayoutParams().get().flags; + boolean r = !((flags & WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE) != 0); + return r; + } + }; + } + + /** + * Matches {@link Root}s that can receive touch events. + */ + public static Matcher<Root> isTouchable() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is touchable"); + } + + @Override + public boolean matchesSafely(Root root) { + int flags = root.getWindowLayoutParams().get().flags; + boolean r = !((flags & WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE) != 0); + return r; + } + }; + } + + /** + * Matches {@link Root}s that are dialogs (i.e. is not a window of the currently resumed + * activity). + */ + public static Matcher<Root> isDialog() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is dialog"); + } + + @Override + public boolean matchesSafely(Root root) { + int type = root.getWindowLayoutParams().get().type; + if ((type != WindowManager.LayoutParams.TYPE_BASE_APPLICATION + && type < WindowManager.LayoutParams.LAST_APPLICATION_WINDOW)) { + IBinder windowToken = root.getDecorView().getWindowToken(); + IBinder appToken = root.getDecorView().getApplicationWindowToken(); + if (windowToken == appToken) { + // windowToken == appToken means this window isn't contained by any other windows. + // if it was a window for an activity, it would have TYPE_BASE_APPLICATION. + // therefore it must be a dialog box. + return true; + } + } + return false; + } + }; + } + + /** + * Matches {@link Root}s with decor views that match the given view matcher. + */ + public static Matcher<Root> withDecorView(final Matcher<View> decorViewMatcher) { + checkNotNull(decorViewMatcher); + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("with decor view "); + decorViewMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(Root root) { + return decorViewMatcher.matches(root.getDecorView()); + } + }; + } + + private static Matcher<View> hasWindowFocus() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has window focus"); + } + + @Override + public boolean matchesSafely(View view) { + return view.hasWindowFocus(); + } + }; + } + + private static Matcher<Root> hasWindowLayoutParams() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("has window layout params"); + } + + @Override + public boolean matchesSafely(Root root) { + if (!root.getWindowLayoutParams().isPresent()) { + return false; + } + return true; + } + }; + } + + private static Matcher<Root> isSubwindowOfCurrentActivity() { + return new TypeSafeMatcher<Root>() { + + @Override + public void describeTo(Description description) { + description.appendText("is subwindow of current activity"); + } + + @Override + public boolean matchesSafely(Root root) { + boolean r = + getResumedActivityTokens().contains(root.getDecorView().getApplicationWindowToken()); + return r; + } + }; + } + + private static List<IBinder> getResumedActivityTokens() { + ActivityLifecycleMonitor activityLifecycleMonitor = + ActivityLifecycleMonitorRegistry.getInstance(); + Collection<Activity> resumedActivities = + activityLifecycleMonitor.getActivitiesInStage(Stage.RESUMED); + if (resumedActivities.isEmpty()) { + throw new NoActivityResumedException("At least one activity should be in RESUMED stage."); + } + List<IBinder> tokens = Lists.newArrayList(); + for (Activity activity : resumedActivities) { + tokens.add(activity.getWindow().getDecorView().getApplicationWindowToken()); + } + return tokens; + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java new file mode 100644 index 0000000..286f494 --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java @@ -0,0 +1,809 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.matcher; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.breadthFirstViewTraversal; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.base.Preconditions.checkState; +import static org.hamcrest.Matchers.is; + +import com.google.android.apps.common.testing.ui.espresso.util.HumanReadables; +import com.google.common.base.Predicate; +import com.google.common.collect.Iterables; + +import android.content.res.Resources; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Checkable; +import android.widget.TextView; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.Matchers; +import org.hamcrest.StringDescription; +import org.hamcrest.TypeSafeMatcher; + +import java.util.Iterator; + +/** + * A collection of hamcrest matchers that match {@link View}s. + */ +public final class ViewMatchers { + + private ViewMatchers() {} + + /** + * Returns a matcher that matches Views which are an instance of or subclass of the provided + * class. Some versions of Hamcrest make the generic typing of this a nightmare, so we have a + * special case for our users. + */ + public static Matcher<View> isAssignableFrom(final Class<? extends View> clazz) { + checkNotNull(clazz); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is assignable from class: " + clazz); + } + + @Override + public boolean matchesSafely(View view) { + return clazz.isAssignableFrom(view.getClass()); + } + }; + } + + /** + * Returns a matcher that matches Views with class name matching the given matcher. + */ + public static Matcher<View> withClassName(final Matcher<String> classNameMatcher) { + checkNotNull(classNameMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with class name: "); + classNameMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return classNameMatcher.matches(view.getClass().getName()); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are currently displayed on the screen to the + * user. + * + * Note: isDisplayed will select views that are partially displayed (eg: the full height/width of + * the view is greater then the height/width of the visible rectangle). If you wish to ensure the + * entire rectangle this view draws is displayed to the user use isCompletelyDisplayed. + */ + public static Matcher<View> isDisplayed() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is displayed on the screen to the user"); + } + + @Override + public boolean matchesSafely(View view) { + return view.getGlobalVisibleRect(new Rect()) && + withEffectiveVisibility(Visibility.VISIBLE).matches(view); + } + }; + } + + /** + * Returns a matcher which only accepts a view whose height and width fit perfectly within + * the currently displayed region of this view. + * + * There exist views (such as ScrollViews) whose height and width are larger then the physical + * device screen by design. Such views will _never_ be completely displayed. + */ + public static Matcher<View> isCompletelyDisplayed() { + return isDisplayingAtLeast(100); + } + + /** + * Returns a matcher which accepts a view so long as a given percentage of that view's area is + * not obscured by any other view and is thus visible to the user. + * + * @param areaPercentage an integer ranging from (0, 100] indicating how much percent of the + * surface area of the view must be shown to the user to be accepted. + */ + public static Matcher<View> isDisplayingAtLeast(final int areaPercentage) { + checkState(areaPercentage <= 100, "Cannot have over 100 percent: %s", areaPercentage); + checkState(areaPercentage > 0, "Must have a positive, non-zero value: %s", areaPercentage); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText(String.format( + "at least %s percent of the view's area is displayed to the user.", areaPercentage)); + } + + @Override + public boolean matchesSafely(View view) { + Rect visibleParts = new Rect(); + boolean visibleAtAll = view.getGlobalVisibleRect(visibleParts); + if (!visibleAtAll) { + return false; + } + double maxArea = view.getHeight() * view.getWidth(); + double visibleArea = visibleParts.height() * visibleParts.width(); + int displayedPercentage = (int) ((visibleArea / maxArea) * 100); + + return displayedPercentage >= areaPercentage + && withEffectiveVisibility(Visibility.VISIBLE).matches(view); + } + }; + } + + + + /** + * Returns a matcher that matches {@link View}s that are enabled. + */ + public static Matcher<View> isEnabled() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is enabled"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isEnabled(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are focusable. + */ + public static Matcher<View> isFocusable() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is focusable"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isFocusable(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s currently have focus. + */ + public static Matcher<View> hasFocus() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has focus on the screen to the user"); + } + + @Override + public boolean matchesSafely(View view) { + return view.hasFocus(); + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on their siblings.<br> + * <br> + * This may be particularly useful when a view cannot be uniquely selected on properties such as + * text or R.id. For example: a call button is repeated several times in a contacts layout and the + * only way to differentiate the call button view is by what appears next to it (e.g. the unique + * name of the contact). + * + * @param siblingMatcher a {@link Matcher} for the sibling of the view. + */ + public static Matcher<View> hasSibling(final Matcher<View> siblingMatcher) { + checkNotNull(siblingMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has sibling: "); + siblingMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + ViewParent parent = view.getParent(); + if (!(parent instanceof ViewGroup)) { + return false; + } + ViewGroup parentGroup = (ViewGroup) parent; + for (int i = 0; i < parentGroup.getChildCount(); i++) { + if (siblingMatcher.matches(parentGroup.getChildAt(i))) { + return true; + } + } + return false; + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on content description property + * value. Sugar for withContentDescription(is("string")). + * + * @param text the text to match on. + */ + public static Matcher<View> withContentDescription(String text) { + return withContentDescription(is(text)); + } + + /** + * Returns an {@link Matcher} that matches {@link View}s based on content description property + * value. + * + * @param charSequenceMatcher a {@link CharSequence} {@link Matcher} for the content description + */ + public static Matcher<View> withContentDescription( + final Matcher<? extends CharSequence> charSequenceMatcher) { + checkNotNull(charSequenceMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with content description: "); + charSequenceMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return charSequenceMatcher.matches(view.getContentDescription()); + } + }; + } + + /** + * Sugar for withId(is(int)). + * + * @param id the resource id. + */ + public static Matcher<View> withId(int id) { + return withId(is(id)); + } + + /** + * Returns a matcher that matches {@link View}s based on resource ids. Note: Android resource ids + * are not guaranteed to be unique. You may have to pair this matcher with another one to + * guarantee a unique view selection. + * + * @param integerMatcher a Matcher for resource ids + */ + public static Matcher<View> withId(final Matcher<Integer> integerMatcher) { + checkNotNull(integerMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with id: "); + integerMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return integerMatcher.matches(view.getId()); + } + }; + } + + /** + * Returns a matcher that matches {@link View} based on tag keys. + * + * @param key to match + */ + public static Matcher<View> withTagKey(final int key) { + return withTagKey(key, Matchers.<Object>notNullValue()); + } + + /** + * Returns a matcher that matches {@link View}s based on tag keys. + * + * @param key to match + * @param objectMatcher Object to match + */ + public static Matcher<View> withTagKey(final int key, final Matcher<Object> objectMatcher) { + checkNotNull(objectMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with key: " + key); + objectMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return objectMatcher.matches(view.getTag(key)); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on tag property values. + * + * @param tagValueMatcher a Matcher for the view's tag property value + */ + public static Matcher<View> withTagValue(final Matcher<Object> tagValueMatcher) { + checkNotNull(tagValueMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("with tag value: "); + tagValueMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return tagValueMatcher.matches(view.getTag()); + } + }; + } + + /** + * Returns a matcher that matches {@link TextView} based on it's text property value. Note: View's + * Sugar for withText(is("string")). + */ + public static Matcher<View> withText(String text) { + return withText(is(text)); + } + + /** + * Returns a matcher that matches {@link TextView}s based on text property value. Note: View's + * text property is never null. If you setText(null) it will still be "". Do not use null matcher. + * + * @param stringMatcher {@link Matcher} of {@link String} with text to match + */ + public static Matcher<View> withText(final Matcher<String> stringMatcher) { + checkNotNull(stringMatcher); + return new BoundedMatcher<View, TextView>(TextView.class) { + @Override + public void describeTo(Description description) { + description.appendText("with text: "); + stringMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(TextView textView) { + return stringMatcher.matches(textView.getText().toString()); + } + }; + } + + /** + * Returns a matcher that matches a descendant of {@link TextView} that is displaying the string + * associated with the given resource id. + * + * @param resourceId the string resource the text view is expected to hold. + */ + public static Matcher<View> withText(final int resourceId) { + + return new BoundedMatcher<View, TextView>(TextView.class) { + private String resourceName = null; + private String expectedText = null; + + @Override + public void describeTo(Description description) { + description.appendText("with string from resource id: "); + description.appendValue(resourceId); + if (null != resourceName) { + description.appendText("["); + description.appendText(resourceName); + description.appendText("]"); + } + if (null != expectedText) { + description.appendText(" value: "); + description.appendText(expectedText); + } + } + + @Override + public boolean matchesSafely(TextView textView) { + if (null == expectedText) { + try { + expectedText = textView.getResources().getString(resourceId); + resourceName = textView.getResources().getResourceEntryName(resourceId); + } catch (Resources.NotFoundException ignored) { + /* view could be from a context unaware of the resource id. */ + } + } + if (null != expectedText) { + return expectedText.equals(textView.getText()); + } else { + return false; + } + } + }; + } + + /** + * Returns a matcher that accepts if and only if the view is a CompoundButton (or subtype of) and + * is in checked state. + */ + public static Matcher<View> isChecked() { + return withCheckBoxState(is(true)); + } + + /** + * Returns a matcher that accepts if and only if the view is a CompoundButton (or subtype of) and + * is not in checked state. + */ + public static Matcher<View> isNotChecked() { + return withCheckBoxState(is(false)); + } + + private static <E extends View & Checkable> Matcher<View> withCheckBoxState( + final Matcher<Boolean> checkStateMatcher) { + + return new BoundedMatcher<View, E>(View.class, Checkable.class) { + @Override + public void describeTo(Description description) { + description.appendText("with checkbox state: "); + checkStateMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(E checkable) { + return checkStateMatcher.matches(checkable.isChecked()); + } + }; + } + + /** + * Returns an {@link Matcher} that matches {@link View}s with any content description. + */ + public static Matcher<View> hasContentDescription() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has content description"); + } + + @Override + public boolean matchesSafely(View view) { + return view.getContentDescription() != null; + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on the presence of a descendant in its view + * hierarchy. + * + * @param descendantMatcher the type of the descendant to match on + */ + public static Matcher<View> hasDescendant(final Matcher<View> descendantMatcher) { + checkNotNull(descendantMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has descendant: "); + descendantMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(final View view) { + final Predicate<View> matcherPredicate = new Predicate<View>() { + @Override + public boolean apply(View input) { + return input != view && descendantMatcher.matches(input); + } + }; + + Iterator<View> matchedViewIterator = + Iterables.filter(breadthFirstViewTraversal(view), matcherPredicate).iterator(); + + return matchedViewIterator.hasNext(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that are clickable. + */ + public static Matcher<View> isClickable() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is clickable"); + } + + @Override + public boolean matchesSafely(View view) { + return view.isClickable(); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s based on the given ancestor type. + * + * @param ancestorMatcher the type of the ancestor to match on + */ + public static Matcher<View> isDescendantOfA(final Matcher<View> ancestorMatcher) { + checkNotNull(ancestorMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is descendant of a: "); + ancestorMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return checkAncestors(view.getParent(), ancestorMatcher); + } + + private boolean checkAncestors( + ViewParent viewParent, Matcher<View> ancestorMatcher) { + if (!(viewParent instanceof View)) { + return false; + } + if (ancestorMatcher.matches(viewParent)) { + return true; + } + return checkAncestors(viewParent.getParent(), ancestorMatcher); + } + }; + } + + /** + * Returns a matcher that matches {@link View}s that have "effective" visibility set to the given + * value. Effective visibility takes into account not only the view's visibility value, but also + * that of its ancestors. In case of View.VISIBLE, this means that the view and all of its + * ancestors have visibility=VISIBLE. In case of GONE and INVISIBLE, it's the opposite - any GONE + * or INVISIBLE parent will make all of its children have their effective visibility. + * + * <p> + * <p> + * Note: Contrary to what the name may imply, view visibility does not directly translate into + * whether the view is displayed on screen (use isDisplayed() for that). For example, the view and + * all of its ancestors can have visibility=VISIBLE, but the view may need to be scrolled to in + * order to be actually visible to the user. Unless you're specifically targeting the visibility + * value with your test, use isDisplayed. + */ + public static Matcher<View> withEffectiveVisibility(final Visibility visibility) { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText( + String.format("view has effective visibility=%s", visibility)); + } + + @Override + public boolean matchesSafely(View view) { + if (visibility.getValue() == View.VISIBLE) { + if (view.getVisibility() != visibility.getValue()) { + return false; + } + while (view.getParent() != null && view.getParent() instanceof View) { + view = (View) view.getParent(); + if (view.getVisibility() != visibility.getValue()) { + return false; + } + } + return true; + } else { + if (view.getVisibility() == visibility.getValue()) { + return true; + } + while (view.getParent() != null && view.getParent() instanceof View) { + view = (View) view.getParent(); + if (view.getVisibility() == visibility.getValue()) { + return true; + } + } + return false; + } + } + }; + } + + /** + * Enumerates the possible list of values for View.getVisibility(). + */ + public enum Visibility { + VISIBLE(View.VISIBLE), INVISIBLE(View.INVISIBLE), GONE(View.GONE); + + private final int value; + + private Visibility(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + } + + /** + * A matcher that accepts a view if and only if the view's parent is accepted by the provided + * matcher. + * + * @param parentMatcher the matcher to apply on getParent. + */ + public static Matcher<View> withParent(final Matcher<View> parentMatcher) { + checkNotNull(parentMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has parent matching: "); + parentMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + return parentMatcher.matches(view.getParent()); + } + }; + } + + /** + * A matcher that returns true if and only if the view's child is accepted by the provided + * matcher. + * + * @param childMatcher the matcher to apply on the child views. + */ + public static Matcher<View> withChild(final Matcher<View> childMatcher) { + checkNotNull(childMatcher); + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has child: "); + childMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + if (!(view instanceof ViewGroup)) { + return false; + } + + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + if (childMatcher.matches(group.getChildAt(i))) { + return true; + } + } + + return false; + } + }; + } + + + /** + * Returns a matcher that matches root {@link View}. + */ + public static Matcher<View> isRoot() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("is a root view."); + } + + @Override + public boolean matchesSafely(View view) { + return view.getRootView().equals(view); + } + }; + } + + /** + * Returns a matcher that matches views that support input methods. + */ + public static Matcher<View> supportsInputMethods() { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("supports input methods"); + } + + @Override + public boolean matchesSafely(View view) { + // At first glance, it would make sense to use view.onCheckIsTextEditor, but the android + // javadoc is wishy-washy about whether authors are required to implement this method when + // implementing onCreateInputConnection. + return view.onCreateInputConnection(new EditorInfo()) != null; + } + }; + } + + /** + * Returns a matcher that matches views that support input methods (e.g. EditText) and have the + * specified IME action set in its {@link EditorInfo}. + * + * @param imeAction the IME action to match + */ + public static Matcher<View> hasImeAction(int imeAction) { + return hasImeAction(is(imeAction)); + } + + /** + * Returns a matcher that matches views that support input methods (e.g. EditText) and have the + * specified IME action set in its {@link EditorInfo}. + * + * @param imeActionMatcher a matcher for the IME action + */ + public static Matcher<View> hasImeAction(final Matcher<Integer> imeActionMatcher) { + return new TypeSafeMatcher<View>() { + @Override + public void describeTo(Description description) { + description.appendText("has ime action: "); + imeActionMatcher.describeTo(description); + } + + @Override + public boolean matchesSafely(View view) { + EditorInfo editorInfo = new EditorInfo(); + InputConnection inputConnection = view.onCreateInputConnection(editorInfo); + if (inputConnection == null) { + return false; + } + int actionId = editorInfo.actionId != 0 ? editorInfo.actionId + : editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + return imeActionMatcher.matches(actionId); + } + }; + } + + /** + * A replacement for MatcherAssert.assertThat that renders View objects nicely. + * + * @param actual the actual value. + * @param matcher a matcher that accepts or rejects actual. + */ + public static <T> void assertThat(T actual, Matcher<T> matcher) { + assertThat("", actual, matcher); + } + + /** + * A replacement for MatcherAssert.assertThat that renders View objects nicely. + * + * @param message the message to display. + * @param actual the actual value. + * @param matcher a matcher that accepts or rejects actual. + */ + public static <T> void assertThat(String message, T actual, Matcher<T> matcher) { + if (!matcher.matches(actual)) { + Description description = new StringDescription(); + description.appendText(message) + .appendText("\nExpected: ") + .appendDescriptionOf(matcher) + .appendText("\n Got: "); + if (actual instanceof View) { + description.appendValue(HumanReadables.describe((View) actual)); + } else { + description.appendValue(actual); + } + description.appendText("\n"); + throw new AssertionFailedError(description.toString()); + } + } +} + diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java new file mode 100644 index 0000000..a160cea --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java @@ -0,0 +1,201 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.util; + +import static com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.depthFirstViewTraversalWithDistance; + +import com.google.android.apps.common.testing.ui.espresso.util.TreeIterables.ViewAndDistance; +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.base.Objects; +import com.google.common.base.Objects.ToStringHelper; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.Iterables; + +import android.content.res.Resources; +import android.os.Build; +import android.util.Printer; +import android.util.StringBuilderPrinter; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.widget.Checkable; +import android.widget.TextView; + +import java.util.List; + +/** + * Text converters for various Android objects. + */ +public final class HumanReadables { + + private HumanReadables() {} + + /** + * Prints out an error message feature the view hierarchy starting at the rootView. + * + * @param rootView the root of the hierarchy tree to print out. + * @param problemViews list of the views that you would like to point out are causing the error + * message or null, if you want to skip this feature. + * @param errorHeader the header of the error message (should contain the description of why the + * error is happening). + * @param problemViewSuffix the message to append to the view description in the tree printout. + * Required if problemViews is supplied. Otherwise, null is acceptable. + * @return a string for human consumption. + */ + public static String getViewHierarchyErrorMessage(View rootView, + final List<View> problemViews, + String errorHeader, + final String problemViewSuffix) { + Preconditions.checkArgument(problemViews == null || problemViewSuffix != null); + StringBuilder errorMessage = new StringBuilder(errorHeader); + if (problemViewSuffix != null) { + errorMessage.append( + String.format("\nProblem views are marked with '%s' below.", problemViewSuffix)); + } + + errorMessage.append("\n\nView Hierarchy:\n"); + + Joiner.on("\n").appendTo(errorMessage, Iterables.transform( + depthFirstViewTraversalWithDistance(rootView), new Function<ViewAndDistance, String>() { + @Override + public String apply(ViewAndDistance viewAndDistance) { + String formatString = "+%s%s "; + if (problemViews != null + && problemViews.contains(viewAndDistance.getView())) { + formatString += problemViewSuffix; + } + formatString += "\n|"; + + return String.format(formatString, + Strings.padStart(">", viewAndDistance.getDistanceFromRoot() + 1, '-'), + HumanReadables.describe(viewAndDistance.getView())); + } + })); + + return errorMessage.toString(); + } + + /** + * Transforms an arbitrary view into a string with (hopefully) enough debug info. + * + * @param v nullable view + * @return a string for human consumption. + */ + public static String describe(View v) { + if (null == v) { + return "null"; + } + ToStringHelper helper = Objects.toStringHelper(v).add("id", v.getId()); + if (v.getId() != -1 && v.getResources() != null) { + try { + helper.add("res-name", v.getResources().getResourceEntryName(v.getId())); + } catch (Resources.NotFoundException ignore) { + // Do nothing. + } + } + if (null != v.getContentDescription()) { + helper.add("desc", v.getContentDescription()); + } + + switch (v.getVisibility()) { + case View.GONE: + helper.add("visibility", "GONE"); + break; + case View.INVISIBLE: + helper.add("visibility", "INVISIBLE"); + break; + case View.VISIBLE: + helper.add("visibility", "VISIBLE"); + break; + default: + helper.add("visibility", v.getVisibility()); + } + + helper.add("width", v.getWidth()) + .add("height", v.getHeight()) + .add("has-focus", v.hasFocus()) + .add("has-focusable", v.hasFocusable()) + .add("has-window-focus", v.hasWindowFocus()) + .add("is-clickable", v.isClickable()) + .add("is-enabled", v.isEnabled()) + .add("is-focused", v.isFocused()) + .add("is-focusable", v.isFocusable()) + .add("is-layout-requested", v.isLayoutRequested()) + .add("is-selected", v.isSelected()); + + if (null != v.getRootView()) { + // pretty much only true in unit-tests. + helper.add("root-is-layout-requested", v.getRootView().isLayoutRequested()); + } + + EditorInfo ei = new EditorInfo(); + InputConnection ic = v.onCreateInputConnection(ei); + boolean hasInputConnection = ic != null; + helper.add("has-input-connection", hasInputConnection); + if (hasInputConnection) { + StringBuilder sb = new StringBuilder(); + sb.append("["); + Printer p = new StringBuilderPrinter(sb); + ei.dump(p, ""); + sb.append("]"); + helper.add("editor-info", sb.toString().replace("\n", " ")); + } + + if (Build.VERSION.SDK_INT > 10) { + helper.add("x", v.getX()).add("y", v.getY()); + } + + if (v instanceof TextView) { + innerDescribe((TextView) v, helper); + } + if (v instanceof Checkable) { + innerDescribe((Checkable) v, helper); + } + if (v instanceof ViewGroup) { + innerDescribe((ViewGroup) v, helper); + } + return helper.toString(); + } + + private static void innerDescribe(TextView textBox, ToStringHelper helper) { + if (null != textBox.getText()) { + helper.add("text", textBox.getText()); + } + + if (null != textBox.getError()) { + helper.add("error-text", textBox.getError()); + } + + if (null != textBox.getHint()) { + helper.add("hint", textBox.getHint()); + } + + helper.add("input-type", textBox.getInputType()); + helper.add("ime-target", textBox.isInputMethodTarget()); + } + + private static void innerDescribe(Checkable checkable, ToStringHelper helper) { + helper.add("is-checked", checkable.isChecked()); + } + + private static void innerDescribe(ViewGroup viewGroup, ToStringHelper helper) { + helper.add("child-count", viewGroup.getChildCount()); + } +} diff --git a/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java new file mode 100644 index 0000000..7fd0c4f --- /dev/null +++ b/espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java @@ -0,0 +1,300 @@ +/* + * Copyright (C) 2014 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 com.google.android.apps.common.testing.ui.espresso.util; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Function; +import com.google.common.collect.AbstractIterator; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import android.view.View; +import android.view.ViewGroup; + +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Utility methods for iterating over tree structured items. + * + * Since the view hierarchy is a tree - having a method of iterating over its contents + * is useful. + * + * This is generalized for any object which can display tree like qualities - but this + * generalization was done for testability concerns (since creating View hierarchies is a pain). + * + * Only public methods of this utility class are considered public API of the test framework. + */ +public final class TreeIterables { + private static final TreeViewer<View> VIEW_TREE_VIEWER = new ViewTreeViewer(); + + private TreeIterables() { } + + /** + * Creates an iterable that traverses the tree formed by the given root. + * + * Along with iteration order, the distance from the root element is also tracked. + * + * @param root the root view to track from. + * @return An iterable of ViewAndDistance containing the view tree in a depth first order with + * the distance of a given node from the root. + */ + public static Iterable<ViewAndDistance> depthFirstViewTraversalWithDistance(View root) { + final DistanceRecordingTreeViewer<View> distanceRecorder = + new DistanceRecordingTreeViewer<View>(root, VIEW_TREE_VIEWER); + + + return Iterables.transform( + depthFirstTraversal(root, distanceRecorder), + new Function<View, ViewAndDistance>() { + @Override + public ViewAndDistance apply(View view) { + return new ViewAndDistance(view, distanceRecorder.getDistance(view)); + } + }); + } + + /** + * Returns an iterable which iterates thru the provided view and its children in a + * depth-first, in-order traversal. That is to say that for a view such as: + * Root + * / | \ + * A R U + * /| |\ + * B D G N + * Will be iterated: Root, A, B, D, R, G, N, U. + * + * @param root the non-null, root view. + */ + public static Iterable<View> depthFirstViewTraversal(View root) { + return depthFirstTraversal(root, VIEW_TREE_VIEWER); + } + + /** + * Returns an iterable which iterates thru the provided view and its children in a + * breadth-first, row-level-order traversal. That is to say that for a view such as: + * Root + * / | \ + * A R U + * /| |\ + * B D G N + * Will be iterated: Root, A, R, U, B, D, G, N + * + * @param root the non-null, root view. + */ + public static Iterable<View> breadthFirstViewTraversal(View root) { + return breadthFirstTraversal(root, VIEW_TREE_VIEWER); + } + + /** + * Creates a depth first traversing iterator of the tree rooted at root. + * + * @param root the root of the tree + * @param viewer a TreeViewer which can determine leafiness of any instance of T and generate + * Iterables for the direct children of any instance of T. + */ + @VisibleForTesting + static <T> Iterable<T> depthFirstTraversal(final T root, final TreeViewer<T> viewer) { + checkNotNull(root); + checkNotNull(viewer); + return new TreeTraversalIterable<T>(root, TraversalStrategy.DEPTH_FIRST, viewer); + } + + /** + * Creates a breadth first traversing iterator of the tree rooted at root. + * + * @param root the root of the tree + * @param viewer a TreeViewer which can determine leafiness of any instance of T and generate + * Iterables for the direct children of any instance of T. + */ + @VisibleForTesting + static <T> Iterable<T> breadthFirstTraversal(final T root, final TreeViewer<T> viewer) { + checkNotNull(root); + checkNotNull(viewer); + return new TreeTraversalIterable<T>(root, TraversalStrategy.BREADTH_FIRST, viewer); + } + + /** + * Converts a tree into an Iterable of the tree's nodes presented in a given traversal order. + */ + private static class TreeTraversalIterable<T> implements Iterable<T> { + private final T root; + private final TraversalStrategy traversalStrategy; + private final TreeViewer<T> treeViewer; + + private TreeTraversalIterable(T root, TraversalStrategy traversalStrategy, + TreeViewer<T> treeViewer) { + this.root = checkNotNull(root); + this.traversalStrategy = checkNotNull(traversalStrategy); + this.treeViewer = checkNotNull(treeViewer); + } + + @Override + public Iterator<T> iterator() { + final LinkedList<T> nodes = Lists.newLinkedList(); + nodes.add(root); + return new AbstractIterator<T>() { + @Override + public T computeNext() { + if (nodes.isEmpty()) { + return endOfData(); + } else { + T nextItem = checkNotNull(traversalStrategy.next(nodes), "Null items not allowed!"); + traversalStrategy.combineNewChildren(nodes, treeViewer.children(nextItem)); + return nextItem; + } + } + }; + } + } + + private enum TraversalStrategy { + BREADTH_FIRST() { + @Override + <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren) { + nodes.addAll(newChildren); + } + }, DEPTH_FIRST() { + @Override + <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren) { + nodes.addAll(0, newChildren); + } + }; + + abstract <T> void combineNewChildren(LinkedList<T> nodes, Collection<T> newChildren); + <T> T next(LinkedList<T> nodes) { + return nodes.removeFirst(); + } + + } + + /** + * A TreeView providing access to the children of a given View. + * + * The only way views can have children is if they are a subclass of + * ViewGroup. + */ + @VisibleForTesting + static class ViewTreeViewer implements TreeViewer<View> { + @Override + public Collection<View> children(View view) { + checkNotNull(view); + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + int childCount = group.getChildCount(); + List<View> children = Lists.newArrayList(); + for (int i = 0; i < childCount; i++) { + children.add(group.getChildAt(i)); + } + return children; + } else { + return Collections.<View>emptyList(); + } + } + } + + /** + * Provides a tree view of items of instance T and records their distance from + * a well known root. + * + * It is assumed that this TreeViewer will only be called with nodes that it + * has processed via its children method, or the root node itself. Otherwise it + * will not be able to determine distance from the root and an exception will be thrown. + * + * This class is stateful and only provides the correct distances after the underlying + * tree has been iterated over. + */ + @VisibleForTesting + static class DistanceRecordingTreeViewer<T> implements TreeViewer<T> { + private final T root; + private final Map<T, Integer> nodeToDistance = Maps.newHashMap(); + private final TreeViewer<T> delegateViewer; + + DistanceRecordingTreeViewer(T root, TreeViewer<T> delegateViewer) { + this.root = checkNotNull(root); + this.delegateViewer = checkNotNull(delegateViewer); + } + + int getDistance(T node) { + return checkNotNull(nodeToDistance.get(node), "Never seen %s before", node); + } + + @Override + public Collection<T> children(final T node) { + if (node == root) { + // base case. + nodeToDistance.put(node, 0); + } + + int myDistance = getDistance(node); + final int childDistance = myDistance + 1; + Collection<T> children = delegateViewer.children(node); + + for (T child : children) { + nodeToDistance.put(child, childDistance); + } + return children; + } + } + + /** + * Provides a way of viewing any instance of T as a tree so long as there exists a method + * for converting the instance of T into a Collection of that instance's direct children. + * + * This nice, sensible abstraction for dealing with objects with treelike properties was + * stolen from Guava's bug tracker. The Guava team is still working out the way trees + * should be exposed as Guava collections - so we have to provide our own. + */ + @VisibleForTesting + interface TreeViewer<T> { + + /** + * Returns a collection view of the children of this node. + */ + Collection<T> children(T instance); + } + + + + /** + * Represents the distance a given view is from the root view. + */ + public static class ViewAndDistance { + private final View view; + private final int distanceFromRoot; + + private ViewAndDistance(View view, int distanceFromRoot) { + this.view = view; + this.distanceFromRoot = distanceFromRoot; + } + + public View getView() { + return view; + } + + public int getDistanceFromRoot() { + return distanceFromRoot; + } + } +} |