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; } }