KeyEventActionBase.java

/*
 * Copyright (C) 2017 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.test.espresso.action;

import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
import static androidx.test.espresso.util.ActivityLifecycles.hasForegroundActivities;
import static androidx.test.espresso.util.ActivityLifecycles.hasTransitioningActivities;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.Iterables.getOnlyElement;

import android.app.Activity;
import android.os.SystemClock;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import androidx.test.espresso.InjectEventSecurityException;
import androidx.test.espresso.NoActivityResumedException;
import androidx.test.espresso.PerformException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.ViewAction;
import androidx.test.espresso.util.HumanReadables;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitor;
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry;
import androidx.test.runner.lifecycle.Stage;
import java.util.Collection;
import org.hamcrest.Matcher;

/** Enables pressing KeyEvents on views. */
class KeyEventActionBase implements ViewAction {
  private static final String TAG = "KeyEventActionBase";

  public static final int BACK_ACTIVITY_TRANSITION_MILLIS_DELAY = 150;
  public static final int CLEAR_TRANSITIONING_ACTIVITIES_ATTEMPTS = 4;
  public static final int CLEAR_TRANSITIONING_ACTIVITIES_MILLIS_DELAY = 150;

  // TODO(b/35108759): move away from manually registering this field and use annotation instead
  final EspressoKey espressoKey;

  KeyEventActionBase(EspressoKey espressoKey) {
    this.espressoKey = checkNotNull(espressoKey);
  }

  @Override
  public Matcher<View> getConstraints() {
    return isDisplayed();
  }

  @Override
  public String getDescription() {
    return String.format("send %s key event", this.espressoKey);
  }

  @Override
  public void perform(UiController uiController, View view) {
    try {
      if (!sendKeyEvent(uiController)) {
        Log.e(TAG, "Failed to inject espressoKey event: " + this.espressoKey);
        throw new PerformException.Builder()
            .withActionDescription(this.getDescription())
            .withViewDescription(HumanReadables.describe(view))
            .withCause(
                new RuntimeException("Failed to inject espressoKey event " + this.espressoKey))
            .build();
      }
    } catch (InjectEventSecurityException e) {
      Log.e(TAG, "Failed to inject espressoKey event: " + this.espressoKey);
      throw new PerformException.Builder()
          .withActionDescription(this.getDescription())
          .withViewDescription(HumanReadables.describe(view))
          .withCause(e)
          .build();
    }
  }

  private boolean sendKeyEvent(UiController controller) 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.espressoKey.getKeyCode(),
                  0,
                  this.espressoKey.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.espressoKey.getKeyCode(), 0));
    }

    return injected;
  }

  static Activity getCurrentActivity() {
    Collection<Activity> resumedActivities =
        ActivityLifecycleMonitorRegistry.getInstance().getActivitiesInStage(Stage.RESUMED);
    return getOnlyElement(resumedActivities);
  }

  static void waitForStageChangeInitialActivity(UiController controller, Activity initialActivity) {
    if (isActivityResumed(initialActivity)) {
      // The activity transition hasn't happened yet, wait for it.
      controller.loopMainThreadForAtLeast(BACK_ACTIVITY_TRANSITION_MILLIS_DELAY);
      if (isActivityResumed(initialActivity)) {
        Log.e(
            TAG,
            "Back was pressed but there was no Activity stage transition in "
                + BACK_ACTIVITY_TRANSITION_MILLIS_DELAY
                + "ms, possibly due to a delay calling super.onBackPressed() from your Activity.");
      }
    }
  }

  private static boolean isActivityResumed(Activity activity) {
    return ActivityLifecycleMonitorRegistry.getInstance().getLifecycleStageOf(activity)
        == Stage.RESUMED;
  }

  static void waitForPendingForegroundActivities(UiController controller, boolean conditional) {
    ActivityLifecycleMonitor activityLifecycleMonitor =
        ActivityLifecycleMonitorRegistry.getInstance();
    boolean pendingForegroundActivities = false;
    for (int attempts = 0; attempts < CLEAR_TRANSITIONING_ACTIVITIES_ATTEMPTS; attempts++) {
      controller.loopMainThreadUntilIdle();
      pendingForegroundActivities = hasTransitioningActivities(activityLifecycleMonitor);
      if (pendingForegroundActivities) {
        controller.loopMainThreadForAtLeast(CLEAR_TRANSITIONING_ACTIVITIES_MILLIS_DELAY);
      } else {
        break;
      }
    }

    // Pressing back can kill the app: log a warning.
    if (!hasForegroundActivities(activityLifecycleMonitor)) {
      if (conditional) {
        throw new NoActivityResumedException("Pressed back and killed the app");
      }
      Log.w(TAG, "Pressed back and hopped to a different process or potentially killed the app");
    }

    if (pendingForegroundActivities) {
      Log.e(
          TAG,
          "Back was pressed and left the application in an inconsistent state even after "
              + (CLEAR_TRANSITIONING_ACTIVITIES_MILLIS_DELAY
                  * CLEAR_TRANSITIONING_ACTIVITIES_ATTEMPTS)
              + "ms.");
    }
  }
}