content: /*
   * Copyright (C) 2015 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.android.server.accessibility;
  
  import static android.view.InputDevice.SOURCE_TOUCHSCREEN;
  import static android.view.MotionEvent.ACTION_CANCEL;
  import static android.view.MotionEvent.ACTION_DOWN;
  import static android.view.MotionEvent.ACTION_MOVE;
  import static android.view.MotionEvent.ACTION_POINTER_DOWN;
  import static android.view.MotionEvent.ACTION_POINTER_UP;
  import static android.view.MotionEvent.ACTION_UP;
  
  import static com.android.server.accessibility.GestureUtils.distance;
  
  import static java.lang.Math.abs;
  import static java.util.Arrays.asList;
  import static java.util.Arrays.copyOfRange;
  
  import android.annotation.NonNull;
  import android.annotation.Nullable;
  import android.content.BroadcastReceiver;
  import android.content.Context;
  import android.content.Intent;
  import android.content.IntentFilter;
  import android.os.Handler;
  import android.os.Looper;
  import android.os.Message;
  import android.util.Log;
  import android.util.MathUtils;
  import android.util.Slog;
  import android.util.TypedValue;
  import android.view.GestureDetector;
  import android.view.GestureDetector.SimpleOnGestureListener;
  import android.view.MotionEvent;
  import android.view.MotionEvent.PointerCoords;
  import android.view.MotionEvent.PointerProperties;
  import android.view.ScaleGestureDetector;
  import android.view.ScaleGestureDetector.OnScaleGestureListener;
  import android.view.ViewConfiguration;
  
  import com.android.internal.annotations.VisibleForTesting;
  
  import java.util.ArrayDeque;
  import java.util.Queue;
  
  /**
   * This class handles magnification in response to touch events.
   *
   * The behavior is as follows:
   *
   * 1. Triple tap toggles permanent screen magnification which is magnifying
   *    the area around the location of the triple tap. One can think of the
   *    location of the triple tap as the center of the magnified viewport.
   *    For example, a triple tap when not magnified would magnify the screen
   *    and leave it in a magnified state. A triple tapping when magnified would
   *    clear magnification and leave the screen in a not magnified state.
   *
   * 2. Triple tap and hold would magnify the screen if not magnified and enable
   *    viewport dragging mode until the finger goes up. One can think of this
   *    mode as a way to move the magnified viewport since the area around the
   *    moving finger will be magnified to fit the screen. For example, if the
   *    screen was not magnified and the user triple taps and holds the screen
   *    would magnify and the viewport will follow the user's finger. When the
   *    finger goes up the screen will zoom out. If the same user interaction
   *    is performed when the screen is magnified, the viewport movement will
   *    be the same but when the finger goes up the screen will stay magnified.
   *    In other words, the initial magnified state is sticky.
   *
   * 3. Magnification can optionally be "triggered" by some external shortcut
   *    affordance. When this occurs via {@link #notifyShortcutTriggered()} a
   *    subsequent tap in a magnifiable region will engage permanent screen
   *    magnification as described in #1. Alternatively, a subsequent long-press
   *    or drag will engage magnification with viewport dragging as described in
   *    #2. Once magnified, all following behaviors apply whether magnification
   *    was engaged via a triple-tap or by a triggered shortcut.
   *
   * 4. Pinching with any number of additional fingers when viewport dragging
   *    is enabled, i.e. the user triple tapped and holds, would adjust the
   *    magnification scale which will become the current default magnification
   *    scale. The next time the user magnifies the same magnification scale
   *    would be used.
   *
   * 5. When in a permanent magnified state the user can use two or more fingers
   *    to pan the viewport. Note that in this mode the content is panned as
   *    opposed to the viewport dragging mode in which the viewport is moved.
   *
   * 6. When in a permanent magnified state the user can use two or more
   *    fingers to change the magnification scale which will become the current
   *    default magnification scale. The next time the user magnifies the same
   *    magnification scale would be used.
   *
   * 7. The magnification scale will be persisted in settings and in the cloud.
   */
  @SuppressWarnings("WeakerAccess")
  class MagnificationGestureHandler extends BaseEventStreamTransformation {
      private static final String LOG_TAG = "MagnificationGestureHandler";
  
      private static final boolean DEBUG_ALL = false;
      private static final boolean DEBUG_STATE_TRANSITIONS = false || DEBUG_ALL;
      private static final boolean DEBUG_DETECTING = false || DEBUG_ALL;
      private static final boolean DEBUG_PANNING_SCALING = false || DEBUG_ALL;
      private static final boolean DEBUG_EVENT_STREAM = false || DEBUG_ALL;
  
      // The MIN_SCALE is different from MagnificationController.MIN_SCALE due
      // to AccessibilityService.MagnificationController#setScale() has
      // different scale range
      private static final float MIN_SCALE = 2.0f;
      private static final float MAX_SCALE = MagnificationController.MAX_SCALE;
  
      @VisibleForTesting final MagnificationController mMagnificationController;
  
      @VisibleForTesting final DelegatingState mDelegatingState;
      @VisibleForTesting final DetectingState mDetectingState;
      @VisibleForTesting final PanningScalingState mPanningScalingState;
      @VisibleForTesting final ViewportDraggingState mViewportDraggingState;
  
      private final ScreenStateReceiver mScreenStateReceiver;
  
      /**
       * {@code true} if this detector should detect and respond to triple-tap
       * gestures for engaging and disengaging magnification,
       * {@code false} if it should ignore such gestures
       */
      final boolean mDetectTripleTap;
  
      /**
       * Whether {@link DetectingState#mShortcutTriggered shortcut} is enabled
       */
      final boolean mDetectShortcutTrigger;
  
      @VisibleForTesting State mCurrentState;
      @VisibleForTesting State mPreviousState;
  
      private PointerCoords[] mTempPointerCoords;
      private PointerProperties[] mTempPointerProperties;
  
      private final int mDisplayId;
  
      private final Queue<MotionEvent> mDebugInputEventHistory;
      private final Queue<MotionEvent> mDebugOutputEventHistory;
  
      /**
       * @param context Context for resolving various magnification-related resources
       * @param magnificationController the {@link MagnificationController}
       *
       * @param detectTripleTap {@code true} if this detector should detect and respond to triple-tap
       *                                gestures for engaging and disengaging magnification,
       *                                {@code false} if it should ignore such gestures
       * @param detectShortcutTrigger {@code true} if this detector should be "triggerable" by some
       *                           external shortcut invoking {@link #notifyShortcutTriggered},
       *                           {@code false} if it should ignore such triggers.
       * @param displayId The logical display id.
       */
      public MagnificationGestureHandler(Context context,
              MagnificationController magnificationController,
              boolean detectTripleTap,
              boolean detectShortcutTrigger,
              int displayId) {
          if (DEBUG_ALL) {
              Log.i(LOG_TAG,
                      "MagnificationGestureHandler(detectTripleTap = " + detectTripleTap
                              + ", detectShortcutTrigger = " + detectShortcutTrigger + ")");
          }
  
          mMagnificationController = magnificationController;
          mDisplayId = displayId;
  
          mDelegatingState = new DelegatingState();
          mDetectingState = new DetectingState(context);
          mViewportDraggingState = new ViewportDraggingState();
          mPanningScalingState = new PanningScalingState(context);
  
          mDetectTripleTap = detectTripleTap;
          mDetectShortcutTrigger = detectShortcutTrigger;
  
          if (mDetectShortcutTrigger) {
              mScreenStateReceiver = new ScreenStateReceiver(context, this);
              mScreenStateReceiver.register();
          } else {
              mScreenStateReceiver = null;
          }
  
          mDebugInputEventHistory = DEBUG_EVENT_STREAM ? new ArrayDeque<>() : null;
          mDebugOutputEventHistory = DEBUG_EVENT_STREAM ? new ArrayDeque<>() : null;
  
          transitionTo(mDetectingState);
      }
  
      @Override
      public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
          if (DEBUG_EVENT_STREAM) {
              storeEventInto(mDebugInputEventHistory, event);
              try {
                  onMotionEventInternal(event, rawEvent, policyFlags);
              } catch (Exception e) {
                  throw new RuntimeException(
                          "Exception following input events: " + mDebugInputEventHistory, e);
              }
          } else {
              onMotionEventInternal(event, rawEvent, policyFlags);
          }
      }
  
      private void onMotionEventInternal(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
          if (DEBUG_ALL) Slog.i(LOG_TAG, "onMotionEvent(" + event + ")");
  
          if ((!mDetectTripleTap && !mDetectShortcutTrigger)
                  || !event.isFromSource(SOURCE_TOUCHSCREEN)) {
              dispatchTransformedEvent(event, rawEvent, policyFlags);
              return;
          }
  
          handleEventWith(mCurrentState, event, rawEvent, policyFlags);
      }
  
      private void handleEventWith(State stateHandler,
              MotionEvent event, MotionEvent rawEvent, int policyFlags) {
          // To keep InputEventConsistencyVerifiers within GestureDetectors happy
          mPanningScalingState.mScrollGestureDetector.onTouchEvent(event);
          mPanningScalingState.mScaleGestureDetector.onTouchEvent(event);
  
          stateHandler.onMotionEvent(event, rawEvent, policyFlags);
      }
  
      @Override
      public void clearEvents(int inputSource) {
          if (inputSource == SOURCE_TOUCHSCREEN) {
              clearAndTransitionToStateDetecting();
          }
  
          super.clearEvents(inputSource);
      }
  
      @Override
      public void onDestroy() {
          if (DEBUG_STATE_TRANSITIONS) {
              Slog.i(LOG_TAG, "onDestroy(); delayed = "
                      + MotionEventInfo.toString(mDetectingState.mDelayedEventQueue));
          }
  
          if (mScreenStateReceiver != null) {
              mScreenStateReceiver.unregister();
          }
          // Check if need to reset when MagnificationGestureHandler is the last magnifying service.
          mMagnificationController.resetAllIfNeeded(
                  AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
          clearAndTransitionToStateDetecting();
      }
  
      void notifyShortcutTriggered() {
          if (mDetectShortcutTrigger) {
              boolean wasMagnifying = mMagnificationController.resetIfNeeded(mDisplayId,
                      /* animate */ true);
              if (wasMagnifying) {
                  clearAndTransitionToStateDetecting();
              } else {
                  mDetectingState.toggleShortcutTriggered();
              }
          }
      }
  
      void clearAndTransitionToStateDetecting() {
          mCurrentState = mDetectingState;
          mDetectingState.clear();
          mViewportDraggingState.clear();
          mPanningScalingState.clear();
      }
  
      private void dispatchTransformedEvent(MotionEvent event, MotionEvent rawEvent,
              int policyFlags) {
          if (DEBUG_EVENT_STREAM) {
              storeEventInto(mDebugOutputEventHistory, event);
              try {
                  super.onMotionEvent(event, rawEvent, policyFlags);
              } catch (Exception e) {
                  throw new RuntimeException(
                          "Exception downstream following input events: " + mDebugInputEventHistory
                                  + "\nTransformed into output events: " + mDebugOutputEventHistory,
                          e);
              }
          } else {
              super.onMotionEvent(event, rawEvent, policyFlags);
          }
      }
  
      private static void storeEventInto(Queue<MotionEvent> queue, MotionEvent event) {
          queue.add(MotionEvent.obtain(event));
          // Prune old events
          while (!queue.isEmpty() && (event.getEventTime() - queue.peek().getEventTime() > 5000)) {
              queue.remove().recycle();
          }
      }
  
      private PointerCoords[] getTempPointerCoordsWithMinSize(int size) {
          final int oldSize = (mTempPointerCoords != null) ? mTempPointerCoords.length : 0;
          if (oldSize < size) {
              PointerCoords[] oldTempPointerCoords = mTempPointerCoords;
              mTempPointerCoords = new PointerCoords[size];
              if (oldTempPointerCoords != null) {
                  System.arraycopy(oldTempPointerCoords, 0, mTempPointerCoords, 0, oldSize);
              }
          }
          for (int i = oldSize; i < size; i++) {
              mTempPointerCoords[i] = new PointerCoords();
          }
          return mTempPointerCoords;
      }
  
      private PointerProperties[] getTempPointerPropertiesWithMinSize(int size) {
          final int oldSize = (mTempPointerProperties != null) ? mTempPointerProperties.length
                  : 0;
          if (oldSize < size) {
              PointerProperties[] oldTempPointerProperties = mTempPointerProperties;
              mTempPointerProperties = new PointerProperties[size];
              if (oldTempPointerProperties != null) {
                  System.arraycopy(oldTempPointerProperties, 0, mTempPointerProperties, 0,
                          oldSize);
              }
          }
          for (int i = oldSize; i < size; i++) {
              mTempPointerProperties[i] = new PointerProperties();
          }
          return mTempPointerProperties;
      }
  
      private void transitionTo(State state) {
          if (DEBUG_STATE_TRANSITIONS) {
              Slog.i(LOG_TAG,
                      (State.nameOf(mCurrentState) + " -> " + State.nameOf(state)
                      + " at " + asList(copyOfRange(new RuntimeException().getStackTrace(), 1, 5)))
                      .replace(getClass().getName(), ""));
          }
          mPreviousState = mCurrentState;
          mCurrentState = state;
      }
  
      interface State {
          void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags);
  
          default void clear() {}
  
          default String name() {
              return getClass().getSimpleName();
          }
  
          static String nameOf(@Nullable State s) {
              return s != null ? s.name() : "null";
          }
      }
  
      /**
       * This class determines if the user is performing a scale or pan gesture.
       *
       * Unlike when {@link ViewportDraggingState dragging the viewport}, in panning mode the viewport
       * moves in the same direction as the fingers, and allows to easily and precisely scale the
       * magnification level.
       * This makes it the preferred mode for one-off adjustments, due to its precision and ease of
       * triggering.
       */
      final class PanningScalingState extends SimpleOnGestureListener
              implements OnScaleGestureListener, State {
  
          private final ScaleGestureDetector mScaleGestureDetector;
          private final GestureDetector mScrollGestureDetector;
          final float mScalingThreshold;
  
          float mInitialScaleFactor = -1;
          boolean mScaling;
  
          public PanningScalingState(Context context) {
              final TypedValue scaleValue = new TypedValue();
              context.getResources().getValue(
                      com.android.internal.R.dimen.config_screen_magnification_scaling_threshold,
                      scaleValue, false);
              mScalingThreshold = scaleValue.getFloat();
              mScaleGestureDetector = new ScaleGestureDetector(context, this, Handler.getMain());
              mScaleGestureDetector.setQuickScaleEnabled(false);
              mScrollGestureDetector = new GestureDetector(context, this, Handler.getMain());
          }
  
          @Override
          public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
              int action = event.getActionMasked();
  
              if (action == ACTION_POINTER_UP
                      && event.getPointerCount() == 2 // includes the pointer currently being released
                      && mPreviousState == mViewportDraggingState) {
  
                  persistScaleAndTransitionTo(mViewportDraggingState);
  
              } else if (action == ACTION_UP || action == ACTION_CANCEL) {
  
                  persistScaleAndTransitionTo(mDetectingState);
  
              }
          }
  
          public void persistScaleAndTransitionTo(State state) {
              mMagnificationController.persistScale();
              clear();
              transitionTo(state);
          }
  
          @Override
          public boolean onScroll(MotionEvent first, MotionEvent second,
                  float distanceX, float distanceY) {
              if (mCurrentState != mPanningScalingState) {
                  return true;
              }
              if (DEBUG_PANNING_SCALING) {
                  Slog.i(LOG_TAG, "Panned content by scrollX: " + distanceX
                          + " scrollY: " + distanceY);
              }
              mMagnificationController.offsetMagnifiedRegion(mDisplayId, distanceX,
                      distanceY, AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
              return /* event consumed: */ true;
          }
  
          @Override
          public boolean onScale(ScaleGestureDetector detector) {
              if (!mScaling) {
                  if (mInitialScaleFactor < 0) {
                      mInitialScaleFactor = detector.getScaleFactor();
                      return false;
                  }
                  final float deltaScale = detector.getScaleFactor() - mInitialScaleFactor;
                  mScaling = abs(deltaScale) > mScalingThreshold;
                  return mScaling;
              }
  
              final float initialScale = mMagnificationController.getScale(mDisplayId);
              final float targetScale = initialScale * detector.getScaleFactor();
  
              // Don't allow a gesture to move the user further outside the
              // desired bounds for gesture-controlled scaling.
              final float scale;
              if (targetScale > MAX_SCALE && targetScale > initialScale) {
                  // The target scale is too big and getting bigger.
                  scale = MAX_SCALE;
              } else if (targetScale < MIN_SCALE && targetScale < initialScale) {
                  // The target scale is too small and getting smaller.
                  scale = MIN_SCALE;
              } else {
                  // The target scale may be outside our bounds, but at least
                  // it's moving in the right direction. This avoids a "jump" if
                  // we're at odds with some other service's desired bounds.
                  scale = targetScale;
              }
  
              final float pivotX = detector.getFocusX();
              final float pivotY = detector.getFocusY();
              if (DEBUG_PANNING_SCALING) Slog.i(LOG_TAG, "Scaled content to: " + scale + "x");
              mMagnificationController.setScale(mDisplayId, scale, pivotX, pivotY, false,
                      AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
              return /* handled: */ true;
          }
  
          @Override
          public boolean onScaleBegin(ScaleGestureDetector detector) {
              return /* continue recognizing: */ (mCurrentState == mPanningScalingState);
          }
  
          @Override
          public void onScaleEnd(ScaleGestureDetector detector) {
              clear();
          }
  
          @Override
          public void clear() {
              mInitialScaleFactor = -1;
              mScaling = false;
          }
  
          @Override
          public String toString() {
              return "PanningScalingState{" +
                      "mInitialScaleFactor=" + mInitialScaleFactor +
                      ", mScaling=" + mScaling +
                      '}';
          }
      }
  
      /**
       * This class handles motion events when the event dispatcher has
       * determined that the user is performing a single-finger drag of the
       * magnification viewport.
       *
       * Unlike when {@link PanningScalingState panning}, the viewport moves in the opposite direction
       * of the finger, and any part of the screen is reachable without lifting the finger.
       * This makes it the preferable mode for tasks like reading text spanning full screen width.
       */
      final class ViewportDraggingState implements State {
  
          /** Whether to disable zoom after dragging ends */
          boolean mZoomedInBeforeDrag;
          private boolean mLastMoveOutsideMagnifiedRegion;
  
          @Override
          public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
              final int action = event.getActionMasked();
              switch (action) {
                  case ACTION_POINTER_DOWN: {
                      clear();
                      transitionTo(mPanningScalingState);
                  }
                  break;
                  case ACTION_MOVE: {
                      if (event.getPointerCount() != 1) {
                          throw new IllegalStateException("Should have one pointer down.");
                      }
                      final float eventX = event.getX();
                      final float eventY = event.getY();
                      if (mMagnificationController.magnificationRegionContains(
                              mDisplayId, eventX, eventY)) {
                          mMagnificationController.setCenter(mDisplayId, eventX, eventY,
                                  /* animate */ mLastMoveOutsideMagnifiedRegion,
                                  AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
                          mLastMoveOutsideMagnifiedRegion = false;
                      } else {
                          mLastMoveOutsideMagnifiedRegion = true;
                      }
                  }
                  break;
  
                  case ACTION_UP:
                  case ACTION_CANCEL: {
                      if (!mZoomedInBeforeDrag) zoomOff();
                      clear();
                      transitionTo(mDetectingState);
                  }
                  break;
  
                  case ACTION_DOWN:
                  case ACTION_POINTER_UP: {
                      throw new IllegalArgumentException(
                              "Unexpected event type: " + MotionEvent.actionToString(action));
                  }
              }
          }
  
          @Override
          public void clear() {
              mLastMoveOutsideMagnifiedRegion = false;
          }
  
          @Override
          public String toString() {
              return "ViewportDraggingState{" +
                      "mZoomedInBeforeDrag=" + mZoomedInBeforeDrag +
                      ", mLastMoveOutsideMagnifiedRegion=" + mLastMoveOutsideMagnifiedRegion +
                      '}';
          }
      }
  
      final class DelegatingState implements State {
          /**
           * Time of last {@link MotionEvent#ACTION_DOWN} while in {@link DelegatingState}
           */
          public long mLastDelegatedDownEventTime;
  
          @Override
          public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
  
          	// Ensure that the state at the end of delegation is consistent with the last delegated
              // UP/DOWN event in queue: still delegating if pointer is down, detecting otherwise
              switch (event.getActionMasked()) {
                  case ACTION_UP:
                  case ACTION_CANCEL: {
                      transitionTo(mDetectingState);
                  } break;
  
                  case ACTION_DOWN: {
                  	transitionTo(mDelegatingState);
                      mLastDelegatedDownEventTime = event.getDownTime();
                  } break;
              }
  
              if (getNext() != null) {
                  // We cache some events to see if the user wants to trigger magnification.
                  // If no magnification is triggered we inject these events with adjusted
                  // time and down time to prevent subsequent transformations being confused
                  // by stale events. After the cached events, which always have a down, are
                  // injected we need to also update the down time of all subsequent non cached
                  // events. All delegated events cached and non-cached are delivered here.
                  event.setDownTime(mLastDelegatedDownEventTime);
                  dispatchTransformedEvent(event, rawEvent, policyFlags);
              }
          }
      }
  
      /**
       * This class handles motion events when the event dispatch has not yet
       * determined what the user is doing. It watches for various tap events.
       */
      final class DetectingState implements State, Handler.Callback {
  
          private static final int MESSAGE_ON_TRIPLE_TAP_AND_HOLD = 1;
          private static final int MESSAGE_TRANSITION_TO_DELEGATING_STATE = 2;
  
          final int mLongTapMinDelay;
          final int mSwipeMinDistance;
          final int mMultiTapMaxDelay;
          final int mMultiTapMaxDistance;
  
          private MotionEventInfo mDelayedEventQueue;
          MotionEvent mLastDown;
          private MotionEvent mPreLastDown;
          private MotionEvent mLastUp;
          private MotionEvent mPreLastUp;
  
          @VisibleForTesting boolean mShortcutTriggered;
  
          @VisibleForTesting Handler mHandler = new Handler(Looper.getMainLooper(), this);
  
          public DetectingState(Context context) {
              mLongTapMinDelay = ViewConfiguration.getLongPressTimeout();
              mMultiTapMaxDelay = ViewConfiguration.getDoubleTapTimeout()
                      + context.getResources().getInteger(
                      com.android.internal.R.integer.config_screen_magnification_multi_tap_adjustment);
              mSwipeMinDistance = ViewConfiguration.get(context).getScaledTouchSlop();
              mMultiTapMaxDistance = ViewConfiguration.get(context).getScaledDoubleTapSlop();
          }
  
          @Override
          public boolean handleMessage(Message message) {
              final int type = message.what;
              switch (type) {
                  case MESSAGE_ON_TRIPLE_TAP_AND_HOLD: {
                      MotionEvent down = (MotionEvent) message.obj;
                      transitionToViewportDraggingStateAndClear(down);
                      down.recycle();
                  }
                  break;
                  case MESSAGE_TRANSITION_TO_DELEGATING_STATE: {
                      transitionToDelegatingStateAndClear();
                  }
                  break;
                  default: {
                      throw new IllegalArgumentException("Unknown message type: " + type);
                  }
              }
              return true;
          }
  
          @Override
          public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
              cacheDelayedMotionEvent(event, rawEvent, policyFlags);
              switch (event.getActionMasked()) {
                  case MotionEvent.ACTION_DOWN: {
  
                      mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE);
  
                      if (!mMagnificationController.magnificationRegionContains(
                              mDisplayId, event.getX(), event.getY())) {
  
                          transitionToDelegatingStateAndClear();
  
                      } else if (isMultiTapTriggered(2 /* taps */)) {
  
                          // 3tap and hold
                          afterLongTapTimeoutTransitionToDraggingState(event);
  
                      } else if (isTapOutOfDistanceSlop()) {
  
                          transitionToDelegatingStateAndClear();
  
                      } else if (mDetectTripleTap
                              // If magnified, delay an ACTION_DOWN for mMultiTapMaxDelay
                              // to ensure reachability of
                              // STATE_PANNING_SCALING(triggerable with ACTION_POINTER_DOWN)
                              || mMagnificationController.isMagnifying(mDisplayId)) {
  
                          afterMultiTapTimeoutTransitionToDelegatingState();
  
                      } else {
  
                          // Delegate pending events without delay
                          transitionToDelegatingStateAndClear();
                      }
                  }
                  break;
                  case ACTION_POINTER_DOWN: {
                      if (mMagnificationController.isMagnifying(mDisplayId)) {
                          transitionTo(mPanningScalingState);
                          clear();
                      } else {
                          transitionToDelegatingStateAndClear();
                      }
                  }
                  break;
                  case ACTION_MOVE: {
                      if (isFingerDown()
                              && distance(mLastDown, /* move */ event) > mSwipeMinDistance) {
  
                          // Swipe detected - transition immediately
  
                          // For convenience, viewport dragging takes precedence
                          // over insta-delegating on 3tap&swipe
                          // (which is a rare combo to be used aside from magnification)
                          if (isMultiTapTriggered(2 /* taps */)) {
                              transitionToViewportDraggingStateAndClear(event);
                          } else {
                              transitionToDelegatingStateAndClear();
                          }
                      }
                  }
                  break;
                  case ACTION_UP: {
  
                      mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD);
  
                      if (!mMagnificationController.magnificationRegionContains(
                              mDisplayId, event.getX(), event.getY())) {
  
                          transitionToDelegatingStateAndClear();
  
                      } else if (isMultiTapTriggered(3 /* taps */)) {
  
                          onTripleTap(/* up */ event);
  
                      } else if (
                              // Possible to be false on: 3tap&drag -> scale -> PTR_UP -> UP
                              isFingerDown()
                              //TODO long tap should never happen here
                              && ((timeBetween(mLastDown, mLastUp) >= mLongTapMinDelay)
                                      || (distance(mLastDown, mLastUp) >= mSwipeMinDistance))) {
  
                          transitionToDelegatingStateAndClear();
  
                      }
                  }
                  break;
              }
          }
  
          public boolean isMultiTapTriggered(int numTaps) {
  
              // Shortcut acts as the 2 initial taps
              if (mShortcutTriggered) return tapCount() + 2 >= numTaps;
  
              return mDetectTripleTap
                      && tapCount() >= numTaps
                      && isMultiTap(mPreLastDown, mLastDown)
                      && isMultiTap(mPreLastUp, mLastUp);
          }
  
          private boolean isMultiTap(MotionEvent first, MotionEvent second) {
              return GestureUtils.isMultiTap(first, second, mMultiTapMaxDelay, mMultiTapMaxDistance);
          }
  
          public boolean isFingerDown() {
              return mLastDown != null;
          }
  
          private long timeBetween(@Nullable MotionEvent a, @Nullable MotionEvent b) {
              if (a == null && b == null) return 0;
              return abs(timeOf(a) - timeOf(b));
          }
  
          /**
           * Nullsafe {@link MotionEvent#getEventTime} that interprets null event as something that
           * has happened long enough ago to be gone from the event queue.
           * Thus the time for a null event is a small number, that is below any other non-null
           * event's time.
           *
           * @return {@link MotionEvent#getEventTime}, or {@link Long#MIN_VALUE} if the event is null
           */
          private long timeOf(@Nullable MotionEvent event) {
              return event != null ? event.getEventTime() : Long.MIN_VALUE;
          }
  
          public int tapCount() {
              return MotionEventInfo.countOf(mDelayedEventQueue, ACTION_UP);
          }
  
          /** -> {@link DelegatingState} */
          public void afterMultiTapTimeoutTransitionToDelegatingState() {
              mHandler.sendEmptyMessageDelayed(
                      MESSAGE_TRANSITION_TO_DELEGATING_STATE,
                      mMultiTapMaxDelay);
          }
  
          /** -> {@link ViewportDraggingState} */
          public void afterLongTapTimeoutTransitionToDraggingState(MotionEvent event) {
              mHandler.sendMessageDelayed(
                      mHandler.obtainMessage(MESSAGE_ON_TRIPLE_TAP_AND_HOLD,
                              MotionEvent.obtain(event)),
                      ViewConfiguration.getLongPressTimeout());
          }
  
          @Override
          public void clear() {
              setShortcutTriggered(false);
              removePendingDelayedMessages();
              clearDelayedMotionEvents();
          }
  
          private void removePendingDelayedMessages() {
              mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD);
              mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE);
          }
  
          private void cacheDelayedMotionEvent(MotionEvent event, MotionEvent rawEvent,
                  int policyFlags) {
              if (event.getActionMasked() == ACTION_DOWN) {
                  mPreLastDown = mLastDown;
                  mLastDown = MotionEvent.obtain(event);
              } else if (event.getActionMasked() == ACTION_UP) {
                  mPreLastUp = mLastUp;
                  mLastUp = MotionEvent.obtain(event);
              }
  
              MotionEventInfo info = MotionEventInfo.obtain(event, rawEvent,
                      policyFlags);
              if (mDelayedEventQueue == null) {
                  mDelayedEventQueue = info;
              } else {
                  MotionEventInfo tail = mDelayedEventQueue;
                  while (tail.mNext != null) {
                      tail = tail.mNext;
                  }
                  tail.mNext = info;
              }
          }
  
          private void sendDelayedMotionEvents() {
              while (mDelayedEventQueue != null) {
                  MotionEventInfo info = mDelayedEventQueue;
                  mDelayedEventQueue = info.mNext;
  
                  handleEventWith(mDelegatingState, info.event, info.rawEvent, info.policyFlags);
  
                  info.recycle();
              }
          }
  
          private void clearDelayedMotionEvents() {
              while (mDelayedEventQueue != null) {
                  MotionEventInfo info = mDelayedEventQueue;
                  mDelayedEventQueue = info.mNext;
                  info.recycle();
              }
              mPreLastDown = null;
              mPreLastUp = null;
              mLastDown = null;
              mLastUp = null;
          }
  
          void transitionToDelegatingStateAndClear() {
              transitionTo(mDelegatingState);
              sendDelayedMotionEvents();
              removePendingDelayedMessages();
          }
  
          private void onTripleTap(MotionEvent up) {
  
              if (DEBUG_DETECTING) {
                  Slog.i(LOG_TAG, "onTripleTap(); delayed: "
                          + MotionEventInfo.toString(mDelayedEventQueue));
              }
              clear();
  
              // Toggle zoom
              if (mMagnificationController.isMagnifying(mDisplayId)) {
                  zoomOff();
              } else {
                  zoomOn(up.getX(), up.getY());
              }
          }
  
          void transitionToViewportDraggingStateAndClear(MotionEvent down) {
  
              if (DEBUG_DETECTING) Slog.i(LOG_TAG, "onTripleTapAndHold()");
              clear();
  
              mViewportDraggingState.mZoomedInBeforeDrag =
                      mMagnificationController.isMagnifying(mDisplayId);
  
              zoomOn(down.getX(), down.getY());
  
              transitionTo(mViewportDraggingState);
          }
  
          @Override
          public String toString() {
              return "DetectingState{" +
                      "tapCount()=" + tapCount() +
                      ", mShortcutTriggered=" + mShortcutTriggered +
                      ", mDelayedEventQueue=" + MotionEventInfo.toString(mDelayedEventQueue) +
                      '}';
          }
  
          void toggleShortcutTriggered() {
              setShortcutTriggered(!mShortcutTriggered);
          }
  
          void setShortcutTriggered(boolean state) {
              if (mShortcutTriggered == state) {
                  return;
              }
              if (DEBUG_DETECTING) Slog.i(LOG_TAG, "setShortcutTriggered(" + state + ")");
  
              mShortcutTriggered = state;
              mMagnificationController.setForceShowMagnifiableBounds(mDisplayId, state);
          }
  
          /**
           * Detects if last action down is out of distance slop between with previous
           * one, when triple tap is enabled.
           *
           * @return true if tap is out of distance slop
           */
          boolean isTapOutOfDistanceSlop() {
              if (!mDetectTripleTap) return false;
              if (mPreLastDown == null || mLastDown == null) {
                  return false;
              }
              final boolean outOfDistanceSlop =
                      GestureUtils.distance(mPreLastDown, mLastDown) > mMultiTapMaxDistance;
              if (tapCount() > 0) {
                  return outOfDistanceSlop;
              }
              // There's no tap in the queue here. We still need to check if this is the case that
              // user tap screen quickly and out of distance slop.
              if (outOfDistanceSlop
                      && !GestureUtils.isTimedOut(mPreLastDown, mLastDown, mMultiTapMaxDelay)) {
                  return true;
              }
              return false;
          }
      }
  
      private void zoomOn(float centerX, float centerY) {
          if (DEBUG_DETECTING) Slog.i(LOG_TAG, "zoomOn(" + centerX + ", " + centerY + ")");
  
          final float scale = MathUtils.constrain(
                  mMagnificationController.getPersistedScale(),
                  MIN_SCALE, MAX_SCALE);
          mMagnificationController.setScaleAndCenter(mDisplayId,
                  scale, centerX, centerY,
                  /* animate */ true,
                  AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
      }
  
      private void zoomOff() {
          if (DEBUG_DETECTING) Slog.i(LOG_TAG, "zoomOff()");
          mMagnificationController.reset(mDisplayId, /* animate */ true);
      }
  
      private static MotionEvent recycleAndNullify(@Nullable MotionEvent event) {
          if (event != null) {
              event.recycle();
          }
          return null;
      }
  
      @Override
      public String toString() {
          return "MagnificationGesture{" +
                  "mDetectingState=" + mDetectingState +
                  ", mDelegatingState=" + mDelegatingState +
                  ", mMagnifiedInteractionState=" + mPanningScalingState +
                  ", mViewportDraggingState=" + mViewportDraggingState +
                  ", mDetectTripleTap=" + mDetectTripleTap +
                  ", mDetectShortcutTrigger=" + mDetectShortcutTrigger +
                  ", mCurrentState=" + State.nameOf(mCurrentState) +
                  ", mPreviousState=" + State.nameOf(mPreviousState) +
                  ", mMagnificationController=" + mMagnificationController +
                  ", mDisplayId=" + mDisplayId +
                  '}';
      }
  
      private static final class MotionEventInfo {
  
          private static final int MAX_POOL_SIZE = 10;
          private static final Object sLock = new Object();
          private static MotionEventInfo sPool;
          private static int sPoolSize;
  
          private MotionEventInfo mNext;
          private boolean mInPool;
  
          public MotionEvent event;
          public MotionEvent rawEvent;
          public int policyFlags;
  
          public static MotionEventInfo obtain(MotionEvent event, MotionEvent rawEvent,
                  int policyFlags) {
              synchronized (sLock) {
                  MotionEventInfo info = obtainInternal();
                  info.initialize(event, rawEvent, policyFlags);
                  return info;
              }
          }
  
          @NonNull
          private static MotionEventInfo obtainInternal() {
              MotionEventInfo info;
              if (sPoolSize > 0) {
                  sPoolSize--;
                  info = sPool;
                  sPool = info.mNext;
                  info.mNext = null;
                  info.mInPool = false;
              } else {
                  info = new MotionEventInfo();
              }
              return info;
          }
  
          private void initialize(MotionEvent event, MotionEvent rawEvent,
                  int policyFlags) {
              this.event = MotionEvent.obtain(event);
              this.rawEvent = MotionEvent.obtain(rawEvent);
              this.policyFlags = policyFlags;
          }
  
          public void recycle() {
              synchronized (sLock) {
                  if (mInPool) {
                      throw new IllegalStateException("Already recycled.");
                  }
                  clear();
                  if (sPoolSize < MAX_POOL_SIZE) {
                      sPoolSize++;
                      mNext = sPool;
                      sPool = this;
                      mInPool = true;
                  }
              }
          }
  
          private void clear() {
              event = recycleAndNullify(event);
              rawEvent = recycleAndNullify(rawEvent);
              policyFlags = 0;
          }
  
          static int countOf(MotionEventInfo info, int eventType) {
              if (info == null) return 0;
              return (info.event.getAction() == eventType ? 1 : 0)
                      + countOf(info.mNext, eventType);
          }
  
          public static String toString(MotionEventInfo info) {
              return info == null
                      ? ""
                      : MotionEvent.actionToString(info.event.getAction()).replace("ACTION_", "")
                              + " " + MotionEventInfo.toString(info.mNext);
          }
      }
  
      /**
       * BroadcastReceiver used to cancel the magnification shortcut when the screen turns off
       */
      private static class ScreenStateReceiver extends BroadcastReceiver {
          private final Context mContext;
          private final MagnificationGestureHandler mGestureHandler;
  
          public ScreenStateReceiver(Context context, MagnificationGestureHandler gestureHandler) {
              mContext = context;
              mGestureHandler = gestureHandler;
          }
  
          public void register() {
              mContext.registerReceiver(this, new IntentFilter(Intent.ACTION_SCREEN_OFF));
          }
  
          public void unregister() {
              mContext.unregisterReceiver(this);
          }
  
          @Override
          public void onReceive(Context context, Intent intent) {
              mGestureHandler.mDetectingState.setShortcutTriggered(false);
          }
      }
  }
  