summaryrefslogtreecommitdiff
path: root/espresso/espresso-lib/src/main/java/com/google
diff options
context:
space:
mode:
Diffstat (limited to 'espresso/espresso-lib/src/main/java/com/google')
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AmbiguousViewMatcherException.java140
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AndroidManifest.xml27
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/AppNotIdleException.java75
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/DataInteraction.java207
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Espresso.java255
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/EspressoException.java22
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/FailureHandler.java37
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/GraphHolder.java78
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicies.java100
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingPolicy.java125
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/IdlingResourceTimeoutException.java37
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/InjectEventSecurityException.java36
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoActivityResumedException.java31
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingRootException.java41
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/NoMatchingViewException.java136
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/PerformException.java86
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/Root.java87
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/UiController.java83
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAction.java68
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewAssertion.java52
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewFinder.java36
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteraction.java178
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/ViewInteractionModule.java69
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterDataLoaderAction.java156
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocol.java196
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/AdapterViewProtocols.java158
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ClearTextAction.java50
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CloseKeyboardAction.java140
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/CoordinatesProvider.java33
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EditorAction.java79
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/EspressoKey.java111
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralClickAction.java141
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralLocation.java112
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/GeneralSwipeAction.java108
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/KeyEventAction.java125
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/MotionEvents.java249
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/PrecisionDescriber.java30
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Press.java46
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ScrollToAction.java79
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swipe.java115
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Swiper.java55
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tap.java108
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/Tapper.java54
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/TypeTextAction.java127
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/action/ViewActions.java223
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/assertion/ViewAssertions.java142
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/AsyncTaskPoolMonitor.java208
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/BaseLayerModule.java167
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/CompatAsyncTask.java29
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/Default.java30
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/DefaultFailureHandler.java96
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjectionStrategy.java50
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/EventInjector.java99
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/IdlingResourceRegistry.java289
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/InputManagerEventInjectionStrategy.java162
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/LooperIdlingResource.java130
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/MainThread.java29
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/QueueInterrogator.java169
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootViewPicker.java217
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/RootsOracle.java167
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/SdkAsyncTask.java29
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ThreadPoolExecutorExtractor.java154
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/UiControllerImpl.java535
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/ViewFinderImpl.java133
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/base/WindowManagerEventInjectionStrategy.java150
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/contrib/CountingIdlingResource.java210
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/BoundedMatcher.java77
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/PreferenceMatchers.java185
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/RootMatchers.java217
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/matcher/ViewMatchers.java809
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/HumanReadables.java201
-rw-r--r--espresso/espresso-lib/src/main/java/com/google/android/apps/common/testing/ui/espresso/util/TreeIterables.java300
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;
+ }
+ }
+}