UiControllerImpl.java

/*
 * 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 androidx.test.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.throwIfUnchecked;

import android.annotation.SuppressLint;
import android.os.Build;
import android.os.Debug;
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 androidx.test.espresso.IdlingPolicies;
import androidx.test.espresso.IdlingPolicy;
import androidx.test.espresso.InjectEventSecurityException;
import androidx.test.espresso.UiController;
import androidx.test.espresso.base.IdlingResourceRegistry.IdleNotificationCallback;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.util.concurrent.ThreadFactoryBuilder;
import java.util.BitSet;
import java.util.EnumSet;
import java.util.Iterator;
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.Future;
import java.util.concurrent.FutureTask;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.inject.Singleton;

/** Implementation of {@link UiController}. */
@Singleton
final class UiControllerImpl
    implements InterruptableUiController, Handler.Callback, IdlingUiController {

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

  /** Represents the status of {@link MainThreadInterrogation} */
  private enum InterrogationStatus {
    TIMED_OUT,
    COMPLETED,
    INTERRUPTED
  }

  private final EventInjector eventInjector;
  private final BitSet conditionSet;

  private final ExecutorService keyEventExecutor =
      Executors.newSingleThreadExecutor(
          new ThreadFactoryBuilder().setNameFormat("Espresso Key Event #%d").build());
  private final Looper mainLooper;
  private final IdlingResourceRegistry idlingResourceRegistry;

  private Handler controllerHandler;
  // only updated on main thread.
  private MainThreadInterrogation interrogation;
  private int generation = 0;
  private IdleNotifier<Runnable> asyncIdle;
  private IdleNotifier<Runnable> compatIdle;
  private Provider<IdleNotifier<IdleNotificationCallback>> dynamicIdleProvider;

  @VisibleForTesting
  @Inject
  UiControllerImpl(
      EventInjector eventInjector,
      @SdkAsyncTask IdleNotifier<Runnable> asyncIdle,
      @CompatAsyncTask IdleNotifier<Runnable> compatIdle,
      Provider<IdleNotifier<IdleNotificationCallback>> dynamicIdle,
      Looper mainLooper,
      IdlingResourceRegistry idlingResourceRegistry) {
    this.eventInjector = checkNotNull(eventInjector);
    this.asyncIdle = checkNotNull(asyncIdle);
    this.compatIdle = checkNotNull(compatIdle);
    this.conditionSet = IdleCondition.createConditionSet();
    this.dynamicIdleProvider = checkNotNull(dynamicIdle);
    this.mainLooper = checkNotNull(mainLooper);
    this.idlingResourceRegistry = checkNotNull(idlingResourceRegistry);
  }

  @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.
    @SuppressWarnings("unused") // go/futurereturn-lsc
    Future<?> possiblyIgnoredError = keyEventExecutor.submit(injectTask);

    loopUntil(IdleCondition.KEY_INJECT_HAS_COMPLETED, dynamicIdleProvider.get());

    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);
    Future<?> possiblyIgnoredError = keyEventExecutor.submit(injectTask);
    loopUntil(IdleCondition.MOTION_INJECTION_HAS_COMPLETED, dynamicIdleProvider.get());
    try {
      checkState(injectTask.isDone(), "Motion event injection was signaled - but it wasnt done.");
      return injectTask.get();
    } catch (ExecutionException ee) {
      if (ee.getCause() instanceof InjectEventSecurityException) {
        throw (InjectEventSecurityException) ee.getCause();
      } else {
        throwIfUnchecked(ee.getCause() != null ? ee.getCause() : ee);
        throw new RuntimeException(ee.getCause() != null ? ee.getCause() : ee);
      }
    } catch (InterruptedException neverHappens) {
      // we only call get() after done() is signaled.
      // we should never block.
      throw new RuntimeException(neverHappens);
    } finally {
      loopMainThreadUntilIdle();
    }
  }

  @Override
  public boolean injectMotionEventSequence(final Iterable<MotionEvent> events)
      throws InjectEventSecurityException {
    checkNotNull(events);
    checkState(!Iterables.isEmpty(events), "Expecting non-empty events to inject");
    checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!");
    initialize();
    final Iterator<MotionEvent> mei = events.iterator();
    final long downTime = Iterables.getFirst(events, null).getEventTime();
    final long shift = SystemClock.uptimeMillis() - downTime;
    FutureTask<Boolean> injectTask =
        new SignalingTask<>(
            new Callable<Boolean>() {
              @Override
              public Boolean call() throws Exception {
                boolean success = true;
                while (mei.hasNext()) {
                  MotionEvent me = mei.next();
                  long desiredTime = me.getEventTime() + shift;
                  long timeUntilDesired = desiredTime - SystemClock.uptimeMillis();
                  if (timeUntilDesired > 10) {
                    // This must NOT run in main thread, so it's fine to sleep
                    SystemClock.sleep(timeUntilDesired);
                  }
                  if (mei.hasNext()) {
                    success &= eventInjector.injectMotionEventAsync(me);
                  } else {
                    success &= eventInjector.injectMotionEvent(me);
                  }
                }
                return success;
              }
            },
            IdleCondition.MOTION_INJECTION_HAS_COMPLETED,
            generation);
    Future<?> possiblyIgnoredError = keyEventExecutor.submit(injectTask);
    loopUntil(IdleCondition.MOTION_INJECTION_HAS_COMPLETED, dynamicIdleProvider.get());
    try {
      checkState(injectTask.isDone(), "MotionEvents injection was signaled - but it wasnt done.");
      return injectTask.get();
    } catch (ExecutionException ee) {
      if (ee.getCause() instanceof InjectEventSecurityException) {
        throw (InjectEventSecurityException) ee.getCause();
      } else {
        throwIfUnchecked(ee.getCause() != null ? ee.getCause() : ee);
        throw new RuntimeException(ee.getCause() != null ? ee.getCause() : ee);
      }
    } catch (InterruptedException neverHappens) {
      // we only call get() after done() is signaled.
      // we should never block.
      throw new RuntimeException(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.isEmpty()) {
      Log.w(TAG, "Supplied string is empty resulting in no-op (nothing is typed).");
      return true;
    }

    boolean eventInjected = false;
    KeyCharacterMap keyCharacterMap = getKeyCharacterMap();

    // TODO(b/80130875): 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());
    if (events == null) {
      throw new RuntimeException(
          String.format(
              "Failed to get key events for string %s (i.e. current IME does not understand how to"
                  + " translate the string into key events). As a workaround, you can use"
                  + " replaceText action to set the text directly in the EditText field.",
              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++) {
        // 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 IdlingResourceRegistry getIdlingResourceRegistry() {
    return idlingResourceRegistry;
  }

  @Override
  public void loopMainThreadUntilIdle() {
    initialize();
    checkState(Looper.myLooper() == mainLooper, "Expecting to be on main thread!");
    IdleNotifier<IdleNotificationCallback> dynamicIdle = dynamicIdleProvider.get();
    do {
      EnumSet<IdleCondition> condChecks = EnumSet.noneOf(IdleCondition.class);
      if (!asyncIdle.isIdleNow()) {
        asyncIdle.registerNotificationCallback(
            new SignalingTask<Void>(NO_OP, IdleCondition.ASYNC_TASKS_HAVE_IDLED, generation));

        condChecks.add(IdleCondition.ASYNC_TASKS_HAVE_IDLED);
      }

      if (!compatIdle.isIdleNow()) {
        compatIdle.registerNotificationCallback(
            new SignalingTask<Void>(NO_OP, IdleCondition.COMPAT_TASKS_HAVE_IDLED, generation));
        condChecks.add(IdleCondition.COMPAT_TASKS_HAVE_IDLED);
      }

      if (!dynamicIdle.isIdleNow()) {
        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);
        dynamicIdle.registerNotificationCallback(
            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 {
        dynamicIdle = loopUntil(condChecks, dynamicIdle);
      } finally {
        asyncIdle.cancelCallback();
        compatIdle.cancelCallback();
        dynamicIdle.cancelCallback();
      }
    } while (!asyncIdle.isIdleNow() || !compatIdle.isIdleNow() || !dynamicIdle.isIdleNow());
  }

  @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.postAtTime(
        new SignalingTask<>(NO_OP, IdleCondition.DELAY_HAS_PAST, generation),
        generation,
        SystemClock.uptimeMillis() + millisDelay);
    loopUntil(IdleCondition.DELAY_HAS_PAST, dynamicIdleProvider.get());
    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, IdleNotifier<IdleNotificationCallback> dynamicIdle) {
    loopUntil(EnumSet.of(condition), dynamicIdle);
  }

  /**
   * Loops the main thread until all IdleConditions have been signaled.
   *
   * <p>Once they've been signaled, the conditions are reset and the generation value is
   * incremented.
   *
   * <p>Signals should only be raised through SignalingTask instances, and care should be taken to
   * ensure that the signaling task is created before loopUntil is called.
   *
   * <p>Good:
   *
   * <pre>{@code
   * idlingType.runOnIdle(new SignalingTask(NO_OP, IdleCondition.MY_IDLE_CONDITION, generation));
   * loopUntil(IdleCondition.MY_IDLE_CONDITION);
   * }</pre>
   *
   * <p>Bad:
   *
   * <pre>{@code
   * 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);
   * }</pre>
   */
  private IdleNotifier<IdleNotificationCallback> loopUntil(
      EnumSet<IdleCondition> conditions, IdleNotifier<IdleNotificationCallback> dynamicIdle) {
    IdlingPolicy masterIdlePolicy = IdlingPolicies.getMasterIdlingPolicy();
    try {
      long start = SystemClock.uptimeMillis();
      long end =
          start + masterIdlePolicy.getIdleTimeoutUnit().toMillis(masterIdlePolicy.getIdleTimeout());
      interrogation = new MainThreadInterrogation(conditions, conditionSet, end);

      InterrogationStatus result = Interrogator.loopAndInterrogate(interrogation);
      if (InterrogationStatus.COMPLETED == result) {
        // did not time out, all conditions happy.
        return dynamicIdle;
      } else if (InterrogationStatus.INTERRUPTED == result) {
        Log.w(TAG, "Espresso interrogation of the main thread is interrupted");
        throw new RuntimeException("Espresso interrogation of the main thread is interrupted");
      }

      // timed out... what went wrong?
      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.",
              interrogation.execCount,
              masterIdlePolicy.getIdleTimeout(),
              masterIdlePolicy.getIdleTimeoutUnit().name()));
    } finally {
      generation++;
      for (IdleCondition condition : conditions) {
        condition.reset(conditionSet);
      }
      interrogation = null;
    }
    return dynamicIdle;
  }

  @Override
  public void interruptEspressoTasks() {
    initialize();
    controllerHandler.post(
        new Runnable() {
          @Override
          public void run() {
            if (interrogation != null) {
              interrogation.interruptInterrogation();
              controllerHandler.removeCallbacksAndMessages(generation);
            }
          }
        });
  }

  private static final class MainThreadInterrogation
      implements Interrogator.InterrogationHandler<InterrogationStatus> {
    private final EnumSet<IdleCondition> conditions;
    private final BitSet conditionSet;
    private final long giveUpAtMs;

    private InterrogationStatus status = InterrogationStatus.COMPLETED;
    private int execCount = 0;

    MainThreadInterrogation(
        EnumSet<IdleCondition> conditions, BitSet conditionSet, long giveUpAtMs) {
      this.conditions = conditions;
      this.conditionSet = conditionSet;
      this.giveUpAtMs = giveUpAtMs;
    }

    @Override
    public void quitting() {
      /* can not happen  */
    }

    @Override
    public boolean barrierUp() {
      return continueOrTimeout();
    }

    @Override
    public boolean queueEmpty() {
      if (conditionsMet()) {
        return false;
      }
      return true;
    }

    @Override
    public boolean taskDueSoon() {
      return continueOrTimeout();
    }

    @Override
    public boolean taskDueLong() {
      if (conditionsMet()) {
        return false;
      }
      return true;
    }

    @Override
    public boolean beforeTaskDispatch() {
      execCount++;
      return continueOrTimeout();
    }

    private boolean continueOrTimeout() {
      if (InterrogationStatus.INTERRUPTED == status) {
        return false;
      }
      if (SystemClock.uptimeMillis() >= giveUpAtMs) {
        status = InterrogationStatus.TIMED_OUT;
        return false;
      }
      return true;
    }

    void interruptInterrogation() {
      status = InterrogationStatus.INTERRUPTED;
    }

    @Override
    public InterrogationStatus get() {
      return status;
    }

    private boolean conditionsMet() {
      if (InterrogationStatus.INTERRUPTED == status) {
        return true; // we want to stop.
      }
      boolean conditionsMet = true;
      boolean shouldLogConditionState = execCount > 0 && execCount % 100 == 0;
      for (IdleCondition condition : conditions) {
        if (!condition.isSignaled(conditionSet)) {
          conditionsMet = false;
          if (shouldLogConditionState) {
            Log.w(TAG, "Waiting for: " + condition.name() + " for " + execCount + " iterations.");
          } else {
            break;
          }
        }
      }
      return conditionsMet;
    }
  }

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