MagnificationGestureHandler refactoring and unit test

This is aimed at making MagnificationGestureHandler easier to understand
and reason about

Test: provided unit test + manual magnification test
Change-Id: I958ef0bdd2e6f857a2fab24962b1a06480685732
diff --git a/core/java/android/util/ExceptionUtils.java b/core/java/android/util/ExceptionUtils.java
index 44019c32..da7387f 100644
--- a/core/java/android/util/ExceptionUtils.java
+++ b/core/java/android/util/ExceptionUtils.java
@@ -78,4 +78,12 @@
         propagateIfInstanceOf(t, RuntimeException.class);
         throw new RuntimeException(t);
     }
+
+    /**
+     * Gets the root {@link Throwable#getCause() cause} of {@code t}
+     */
+    public static @NonNull Throwable getRootCause(@NonNull Throwable t) {
+        while (t.getCause() != null) t = t.getCause();
+        return t;
+    }
 }
diff --git a/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java b/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java
index 7324b82..c60647f 100644
--- a/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java
+++ b/services/accessibility/java/com/android/server/accessibility/AccessibilityInputFilter.java
@@ -417,7 +417,8 @@
             final boolean triggerable = (mEnabledFeatures
                     & FLAG_FEATURE_TRIGGERED_SCREEN_MAGNIFIER) != 0;
             mMagnificationGestureHandler = new MagnificationGestureHandler(
-                    mContext, mAms, detectControlGestures, triggerable);
+                    mContext, mAms.getMagnificationController(),
+                    detectControlGestures, triggerable);
             addFirstEventHandler(mMagnificationGestureHandler);
         }
 
diff --git a/services/accessibility/java/com/android/server/accessibility/GestureUtils.java b/services/accessibility/java/com/android/server/accessibility/GestureUtils.java
index bc76191..abfdb68 100644
--- a/services/accessibility/java/com/android/server/accessibility/GestureUtils.java
+++ b/services/accessibility/java/com/android/server/accessibility/GestureUtils.java
@@ -12,32 +12,27 @@
         /* cannot be instantiated */
     }
 
-    public static boolean isTap(MotionEvent down, MotionEvent up, int tapTimeSlop,
-            int tapDistanceSlop, int actionIndex) {
-        return eventsWithinTimeAndDistanceSlop(down, up, tapTimeSlop, tapDistanceSlop, actionIndex);
-    }
-
     public static boolean isMultiTap(MotionEvent firstUp, MotionEvent secondUp,
-            int multiTapTimeSlop, int multiTapDistanceSlop, int actionIndex) {
+            int multiTapTimeSlop, int multiTapDistanceSlop) {
+        if (firstUp == null || secondUp == null) return false;
         return eventsWithinTimeAndDistanceSlop(firstUp, secondUp, multiTapTimeSlop,
-                multiTapDistanceSlop, actionIndex);
+                multiTapDistanceSlop);
     }
 
     private static boolean eventsWithinTimeAndDistanceSlop(MotionEvent first, MotionEvent second,
-            int timeout, int distance, int actionIndex) {
+            int timeout, int distance) {
         if (isTimedOut(first, second, timeout)) {
             return false;
         }
-        final double deltaMove = computeDistance(first, second, actionIndex);
+        final double deltaMove = distance(first, second);
         if (deltaMove >= distance) {
             return false;
         }
         return true;
     }
 
-    public static double computeDistance(MotionEvent first, MotionEvent second, int pointerIndex) {
-         return MathUtils.dist(first.getX(pointerIndex), first.getY(pointerIndex),
-                 second.getX(pointerIndex), second.getY(pointerIndex));
+    public static double distance(MotionEvent first, MotionEvent second) {
+        return MathUtils.dist(first.getX(), first.getY(), second.getX(), second.getY());
     }
 
     public static boolean isTimedOut(MotionEvent firstUp, MotionEvent secondUp, int timeout) {
@@ -54,7 +49,6 @@
     /**
      * Determines whether a two pointer gesture is a dragging one.
      *
-     * @param event The event with the pointer data.
      * @return True if the gesture is a dragging one.
      */
     public static boolean isDraggingGesture(float firstPtrDownX, float firstPtrDownY,
diff --git a/services/accessibility/java/com/android/server/accessibility/MagnificationController.java b/services/accessibility/java/com/android/server/accessibility/MagnificationController.java
index caa74b9..98b8e6b 100644
--- a/services/accessibility/java/com/android/server/accessibility/MagnificationController.java
+++ b/services/accessibility/java/com/android/server/accessibility/MagnificationController.java
@@ -16,11 +16,6 @@
 
 package com.android.server.accessibility;
 
-import com.android.internal.R;
-import com.android.internal.annotations.GuardedBy;
-import com.android.internal.os.SomeArgs;
-import com.android.server.LocalServices;
-
 import android.animation.ValueAnimator;
 import android.annotation.NonNull;
 import android.content.BroadcastReceiver;
@@ -42,6 +37,12 @@
 import android.view.WindowManagerInternal;
 import android.view.animation.DecelerateInterpolator;
 
+import com.android.internal.R;
+import com.android.internal.annotations.GuardedBy;
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.internal.os.SomeArgs;
+import com.android.server.LocalServices;
+
 import java.util.Locale;
 
 /**
@@ -138,7 +139,7 @@
     private final WindowManagerInternal mWindowManager;
 
     // Flag indicating that we are registered with window manager.
-    private boolean mRegistered;
+    @VisibleForTesting boolean mRegistered;
 
     private boolean mUnregisterPending;
 
@@ -148,9 +149,14 @@
         mHandler = new Handler(context.getMainLooper(), this);
     }
 
-    public MagnificationController(Context context, AccessibilityManagerService ams, Object lock,
-            Handler handler, WindowManagerInternal windowManagerInternal,
-            ValueAnimator valueAnimator, SettingsBridge settingsBridge) {
+    public MagnificationController(
+            Context context,
+            AccessibilityManagerService ams,
+            Object lock,
+            Handler handler,
+            WindowManagerInternal windowManagerInternal,
+            ValueAnimator valueAnimator,
+            SettingsBridge settingsBridge) {
         mHandler = handler;
         mWindowManager = windowManagerInternal;
         mMainThreadId = context.getMainLooper().getThread().getId();
@@ -672,8 +678,7 @@
      * Resets magnification if magnification and auto-update are both enabled.
      *
      * @param animate whether the animate the transition
-     * @return {@code true} if magnification was reset to the disabled state,
-     *         {@code false} if magnification is still active
+     * @return whether was {@link #isMagnifying magnifying}
      */
     boolean resetIfNeeded(boolean animate) {
         synchronized (mLock) {
@@ -790,6 +795,19 @@
         return true;
     }
 
+    @Override
+    public String toString() {
+        return "MagnificationController{" +
+                "mCurrentMagnificationSpec=" + mCurrentMagnificationSpec +
+                ", mMagnificationRegion=" + mMagnificationRegion +
+                ", mMagnificationBounds=" + mMagnificationBounds +
+                ", mUserId=" + mUserId +
+                ", mIdOfLastServiceToMagnify=" + mIdOfLastServiceToMagnify +
+                ", mRegistered=" + mRegistered +
+                ", mUnregisterPending=" + mUnregisterPending +
+                '}';
+    }
+
     /**
      * Class responsible for animating spec on the main thread and sending spec
      * updates to the window manager.
diff --git a/services/accessibility/java/com/android/server/accessibility/MagnificationGestureHandler.java b/services/accessibility/java/com/android/server/accessibility/MagnificationGestureHandler.java
index b1ac589..d6452f8 100644
--- a/services/accessibility/java/com/android/server/accessibility/MagnificationGestureHandler.java
+++ b/services/accessibility/java/com/android/server/accessibility/MagnificationGestureHandler.java
@@ -16,6 +16,21 @@
 
 package com.android.server.accessibility;
 
+import static android.view.InputDevice.SOURCE_TOUCHSCREEN;
+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;
@@ -27,7 +42,6 @@
 import android.util.TypedValue;
 import android.view.GestureDetector;
 import android.view.GestureDetector.SimpleOnGestureListener;
-import android.view.InputDevice;
 import android.view.KeyEvent;
 import android.view.MotionEvent;
 import android.view.MotionEvent.PointerCoords;
@@ -37,6 +51,8 @@
 import android.view.ViewConfiguration;
 import android.view.accessibility.AccessibilityEvent;
 
+import com.android.internal.annotations.VisibleForTesting;
+
 /**
  * This class handles magnification in response to touch events.
  *
@@ -85,91 +101,109 @@
  *
  * 7. The magnification scale will be persisted in settings and in the cloud.
  */
+@SuppressWarnings("WeakerAccess")
 class MagnificationGestureHandler implements EventStreamTransformation {
     private static final String LOG_TAG = "MagnificationEventHandler";
 
-    private static final boolean DEBUG_STATE_TRANSITIONS = false;
-    private static final boolean DEBUG_DETECTING = false;
-    private static final boolean DEBUG_PANNING = false;
+    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 = false || DEBUG_ALL;
 
-    private static final int STATE_DELEGATING = 1;
-    private static final int STATE_DETECTING = 2;
-    private static final int STATE_VIEWPORT_DRAGGING = 3;
-    private static final int STATE_MAGNIFIED_INTERACTION = 4;
+    /** @see #handleMotionEventStateDelegating */
+    @VisibleForTesting static final int STATE_DELEGATING = 1;
+    /** @see DetectingStateHandler */
+    @VisibleForTesting static final int STATE_DETECTING = 2;
+    /** @see ViewportDraggingStateHandler */
+    @VisibleForTesting static final int STATE_VIEWPORT_DRAGGING = 3;
+    /** @see PanningScalingStateHandler */
+    @VisibleForTesting static final int STATE_PANNING_SCALING = 4;
 
     private static final float MIN_SCALE = 2.0f;
     private static final float MAX_SCALE = 5.0f;
 
-    private final MagnificationController mMagnificationController;
-    private final DetectingStateHandler mDetectingStateHandler;
-    private final MagnifiedContentInteractionStateHandler mMagnifiedContentInteractionStateHandler;
-    private final StateViewportDraggingHandler mStateViewportDraggingHandler;
+    @VisibleForTesting final MagnificationController mMagnificationController;
+
+    @VisibleForTesting final DetectingStateHandler mDetectingStateHandler;
+    @VisibleForTesting final PanningScalingStateHandler mPanningScalingStateHandler;
+    @VisibleForTesting final ViewportDraggingStateHandler mViewportDraggingStateHandler;
 
     private final ScreenStateReceiver mScreenStateReceiver;
 
-    private final boolean mDetectTripleTap;
-    private final boolean mTriggerable;
+    /**
+     * {@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;
 
-    private EventStreamTransformation mNext;
+    /**
+     * Whether {@link #mShortcutTriggered shortcut} is enabled
+     */
+    final boolean mDetectShortcutTrigger;
 
-    private int mCurrentState;
-    private int mPreviousState;
+    EventStreamTransformation mNext;
 
-    private boolean mTranslationEnabledBeforePan;
+    @VisibleForTesting int mCurrentState;
+    @VisibleForTesting int mPreviousState;
 
-    private boolean mShortcutTriggered;
+    @VisibleForTesting boolean mShortcutTriggered;
+
+    /**
+     * Time of last {@link MotionEvent#ACTION_DOWN} while in {@link #STATE_DELEGATING}
+     */
+    long mDelegatingStateDownTime;
 
     private PointerCoords[] mTempPointerCoords;
     private PointerProperties[] mTempPointerProperties;
 
-    private long mDelegatingStateDownTime;
-
     /**
      * @param context Context for resolving various magnification-related resources
-     * @param ams AccessibilityManagerService used to obtain a {@link MagnificationController}
+     * @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 triggerable {@code true} if this detector should be "triggerable" by some external
-     *                                shortcut invoking {@link #notifyShortcutTriggered}, {@code
-     *                                false} if it should ignore such triggers.
+     *                                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.
      */
-    public MagnificationGestureHandler(Context context, AccessibilityManagerService ams,
-            boolean detectTripleTap, boolean triggerable) {
-        mMagnificationController = ams.getMagnificationController();
-        mDetectingStateHandler = new DetectingStateHandler(context);
-        mStateViewportDraggingHandler = new StateViewportDraggingHandler();
-        mMagnifiedContentInteractionStateHandler =
-                new MagnifiedContentInteractionStateHandler(context);
-        mDetectTripleTap = detectTripleTap;
-        mTriggerable = triggerable;
+    public MagnificationGestureHandler(Context context,
+            MagnificationController magnificationController,
+            boolean detectTripleTap,
+            boolean detectShortcutTrigger) {
+        mMagnificationController = magnificationController;
 
-        if (triggerable) {
+        mDetectingStateHandler = new DetectingStateHandler(context);
+        mViewportDraggingStateHandler = new ViewportDraggingStateHandler();
+        mPanningScalingStateHandler =
+                new PanningScalingStateHandler(context);
+
+        mDetectTripleTap = detectTripleTap;
+        mDetectShortcutTrigger = detectShortcutTrigger;
+
+        if (mDetectShortcutTrigger) {
             mScreenStateReceiver = new ScreenStateReceiver(context, this);
             mScreenStateReceiver.register();
         } else {
             mScreenStateReceiver = null;
         }
 
-        transitionToState(STATE_DETECTING);
+        transitionTo(STATE_DETECTING);
     }
 
     @Override
     public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
-        if (!event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
-            if (mNext != null) {
-                mNext.onMotionEvent(event, rawEvent, policyFlags);
-            }
+        if ((!mDetectTripleTap && !mDetectShortcutTrigger)
+                || !event.isFromSource(SOURCE_TOUCHSCREEN)) {
+            dispatchTransformedEvent(event, rawEvent, policyFlags);
             return;
         }
-        if (!mDetectTripleTap && !mTriggerable) {
-            if (mNext != null) {
-                dispatchTransformedEvent(event, rawEvent, policyFlags);
-            }
-            return;
-        }
-        mMagnifiedContentInteractionStateHandler.onMotionEvent(event, rawEvent, policyFlags);
-        switch (mCurrentState) {
+        // Local copy to avoid dispatching the same event to more than one state handler
+        // in case mPanningScalingStateHandler changes mCurrentState
+        int currentState = mCurrentState;
+        mPanningScalingStateHandler.onMotionEvent(event, rawEvent, policyFlags);
+        switch (currentState) {
             case STATE_DELEGATING: {
                 handleMotionEventStateDelegating(event, rawEvent, policyFlags);
             }
@@ -179,17 +213,17 @@
             }
             break;
             case STATE_VIEWPORT_DRAGGING: {
-                mStateViewportDraggingHandler.onMotionEvent(event, rawEvent, policyFlags);
+                mViewportDraggingStateHandler.onMotionEvent(event, rawEvent, policyFlags);
             }
             break;
-            case STATE_MAGNIFIED_INTERACTION: {
-                // mMagnifiedContentInteractionStateHandler handles events only
+            case STATE_PANNING_SCALING: {
+                // mPanningScalingStateHandler handles events only
                 // if this is the current state since it uses ScaleGestureDetector
                 // and a GestureDetector which need well formed event stream.
             }
             break;
             default: {
-                throw new IllegalStateException("Unknown state: " + mCurrentState);
+                throw new IllegalStateException("Unknown state: " + currentState);
             }
         }
     }
@@ -215,8 +249,8 @@
 
     @Override
     public void clearEvents(int inputSource) {
-        if (inputSource == InputDevice.SOURCE_TOUCHSCREEN) {
-            clear();
+        if (inputSource == SOURCE_TOUCHSCREEN) {
+            clearAndTransitionToStateDetecting();
         }
 
         if (mNext != null) {
@@ -229,20 +263,25 @@
         if (mScreenStateReceiver != null) {
             mScreenStateReceiver.unregister();
         }
-        clear();
+        clearAndTransitionToStateDetecting();
     }
 
     void notifyShortcutTriggered() {
-        if (mTriggerable) {
-            if (mMagnificationController.resetIfNeeded(true)) {
-                clear();
+        if (mDetectShortcutTrigger) {
+            boolean wasMagnifying = mMagnificationController.resetIfNeeded(/* animate */ true);
+            if (wasMagnifying) {
+                clearAndTransitionToStateDetecting();
             } else {
-                setMagnificationShortcutTriggered(!mShortcutTriggered);
+                toggleShortcutTriggered();
             }
         }
     }
 
-    private void setMagnificationShortcutTriggered(boolean state) {
+    private void toggleShortcutTriggered() {
+        setShortcutTriggered(!mShortcutTriggered);
+    }
+
+    private void setShortcutTriggered(boolean state) {
         if (mShortcutTriggered == state) {
             return;
         }
@@ -251,27 +290,25 @@
         mMagnificationController.setForceShowMagnifiableBounds(state);
     }
 
-    private void clear() {
+    void clearAndTransitionToStateDetecting() {
+        setShortcutTriggered(false);
         mCurrentState = STATE_DETECTING;
-        setMagnificationShortcutTriggered(false);
         mDetectingStateHandler.clear();
-        mStateViewportDraggingHandler.clear();
-        mMagnifiedContentInteractionStateHandler.clear();
+        mViewportDraggingStateHandler.clear();
+        mPanningScalingStateHandler.clear();
     }
 
     private void handleMotionEventStateDelegating(MotionEvent event,
             MotionEvent rawEvent, int policyFlags) {
-        switch (event.getActionMasked()) {
-            case MotionEvent.ACTION_DOWN: {
-                mDelegatingStateDownTime = event.getDownTime();
-            }
-            break;
-            case MotionEvent.ACTION_UP: {
-                if (mDetectingStateHandler.mDelayedEventQueue == null) {
-                    transitionToState(STATE_DETECTING);
-                }
-            }
-            break;
+        if (event.getActionMasked() == ACTION_UP) {
+            transitionTo(STATE_DETECTING);
+        }
+        delegateEvent(event, rawEvent, policyFlags);
+    }
+
+    void delegateEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
+        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
+            mDelegatingStateDownTime = event.getDownTime();
         }
         if (mNext != null) {
             // We cache some events to see if the user wants to trigger magnification.
@@ -287,13 +324,15 @@
 
     private void dispatchTransformedEvent(MotionEvent event, MotionEvent rawEvent,
             int policyFlags) {
-        // If the event is within the magnified portion of the screen we have
+        if (mNext == null) return; // Nowhere to dispatch to
+
+        // If the touchscreen event is within the magnified portion of the screen we have
         // to change its location to be where the user thinks he is poking the
         // UI which may have been magnified and panned.
-        final float eventX = event.getX();
-        final float eventY = event.getY();
         if (mMagnificationController.isMagnifying()
-                && mMagnificationController.magnificationRegionContains(eventX, eventY)) {
+                && event.isFromSource(SOURCE_TOUCHSCREEN)
+                && mMagnificationController.magnificationRegionContains(
+                        event.getX(), event.getY())) {
             final float scale = mMagnificationController.getScale();
             final float scaledOffsetX = mMagnificationController.getOffsetX();
             final float scaledOffsetY = mMagnificationController.getOffsetY();
@@ -347,34 +386,27 @@
         return mTempPointerProperties;
     }
 
-    private void transitionToState(int state) {
+    private void transitionTo(int state) {
         if (DEBUG_STATE_TRANSITIONS) {
-            switch (state) {
-                case STATE_DELEGATING: {
-                    Slog.i(LOG_TAG, "mCurrentState: STATE_DELEGATING");
-                }
-                break;
-                case STATE_DETECTING: {
-                    Slog.i(LOG_TAG, "mCurrentState: STATE_DETECTING");
-                }
-                break;
-                case STATE_VIEWPORT_DRAGGING: {
-                    Slog.i(LOG_TAG, "mCurrentState: STATE_VIEWPORT_DRAGGING");
-                }
-                break;
-                case STATE_MAGNIFIED_INTERACTION: {
-                    Slog.i(LOG_TAG, "mCurrentState: STATE_MAGNIFIED_INTERACTION");
-                }
-                break;
-                default: {
-                    throw new IllegalArgumentException("Unknown state: " + state);
-                }
-            }
+            Slog.i(LOG_TAG, (stateToString(mCurrentState) + " -> " + stateToString(state)
+                    + " at " + asList(copyOfRange(new RuntimeException().getStackTrace(), 1, 5)))
+                    .replace(getClass().getName(), ""));
         }
         mPreviousState = mCurrentState;
         mCurrentState = state;
     }
 
+    private static String stateToString(int state) {
+        switch (state) {
+            case STATE_DELEGATING: return "STATE_DELEGATING";
+            case STATE_DETECTING: return "STATE_DETECTING";
+            case STATE_VIEWPORT_DRAGGING: return "STATE_VIEWPORT_DRAGGING";
+            case STATE_PANNING_SCALING: return "STATE_PANNING_SCALING";
+            case 0: return "0";
+            default: throw new IllegalArgumentException("Unknown state: " + state);
+        }
+    }
+
     private interface MotionEventHandler {
 
         void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags);
@@ -384,21 +416,20 @@
 
     /**
      * This class determines if the user is performing a scale or pan gesture.
+     *
+     * @see #STATE_PANNING_SCALING
      */
-    private final class MagnifiedContentInteractionStateHandler extends SimpleOnGestureListener
+    final class PanningScalingStateHandler extends SimpleOnGestureListener
             implements OnScaleGestureListener, MotionEventHandler {
 
         private final ScaleGestureDetector mScaleGestureDetector;
-
         private final GestureDetector mGestureDetector;
+        final float mScalingThreshold;
 
-        private final float mScalingThreshold;
+        float mInitialScaleFactor = -1;
+        boolean mScaling;
 
-        private float mInitialScaleFactor = -1;
-
-        private boolean mScaling;
-
-        public MagnifiedContentInteractionStateHandler(Context context) {
+        public PanningScalingStateHandler(Context context) {
             final TypedValue scaleValue = new TypedValue();
             context.getResources().getValue(
                     com.android.internal.R.dimen.config_screen_magnification_scaling_threshold,
@@ -411,26 +442,39 @@
 
         @Override
         public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) {
+            // Dispatches #onScaleBegin, #onScale, #onScaleEnd
             mScaleGestureDetector.onTouchEvent(event);
+            // Dispatches #onScroll
             mGestureDetector.onTouchEvent(event);
-            if (mCurrentState != STATE_MAGNIFIED_INTERACTION) {
+
+            if (mCurrentState != STATE_PANNING_SCALING) {
                 return;
             }
-            if (event.getActionMasked() == MotionEvent.ACTION_UP) {
-                clear();
-                mMagnificationController.persistScale();
-                if (mPreviousState == STATE_VIEWPORT_DRAGGING) {
-                    transitionToState(STATE_VIEWPORT_DRAGGING);
-                } else {
-                    transitionToState(STATE_DETECTING);
-                }
+
+            int action = event.getActionMasked();
+            if (action == ACTION_POINTER_UP
+                    && event.getPointerCount() == 2 // includes the pointer currently being released
+                    && mPreviousState == STATE_VIEWPORT_DRAGGING) {
+
+                persistScaleAndTransitionTo(STATE_VIEWPORT_DRAGGING);
+
+            } else if (action == ACTION_UP) {
+
+                persistScaleAndTransitionTo(STATE_DETECTING);
+
             }
         }
 
+        public void persistScaleAndTransitionTo(int state) {
+            mMagnificationController.persistScale();
+            clear();
+            transitionTo(state);
+        }
+
         @Override
-        public boolean onScroll(MotionEvent first, MotionEvent second, float distanceX,
-                float distanceY) {
-            if (mCurrentState != STATE_MAGNIFIED_INTERACTION) {
+        public boolean onScroll(MotionEvent first, MotionEvent second,
+                float distanceX, float distanceY) {
+            if (mCurrentState != STATE_PANNING_SCALING) {
                 return true;
             }
             if (DEBUG_PANNING) {
@@ -447,14 +491,15 @@
             if (!mScaling) {
                 if (mInitialScaleFactor < 0) {
                     mInitialScaleFactor = detector.getScaleFactor();
-                } else {
-                    final float deltaScale = detector.getScaleFactor() - mInitialScaleFactor;
-                    if (Math.abs(deltaScale) > mScalingThreshold) {
-                        mScaling = true;
-                        return true;
-                    }
+                    return false;
                 }
-                return false;
+                final float deltaScale = detector.getScaleFactor() - mInitialScaleFactor;
+                if (abs(deltaScale) > mScalingThreshold) {
+                    mScaling = true;
+                    return true;
+                } else {
+                    return false;
+                }
             }
 
             final float initialScale = mMagnificationController.getScale();
@@ -485,7 +530,7 @@
 
         @Override
         public boolean onScaleBegin(ScaleGestureDetector detector) {
-            return (mCurrentState == STATE_MAGNIFIED_INTERACTION);
+            return (mCurrentState == STATE_PANNING_SCALING);
         }
 
         @Override
@@ -498,60 +543,65 @@
             mInitialScaleFactor = -1;
             mScaling = false;
         }
+
+        @Override
+        public String toString() {
+            return "MagnifiedContentInteractionStateHandler{" +
+                    "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.
+     *
+     * @see #STATE_VIEWPORT_DRAGGING
      */
-    private final class StateViewportDraggingHandler implements MotionEventHandler {
+    final class ViewportDraggingStateHandler implements MotionEventHandler {
 
+        /** 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 MotionEvent.ACTION_DOWN: {
-                    throw new IllegalArgumentException("Unexpected event type: ACTION_DOWN");
-                }
-                case MotionEvent.ACTION_POINTER_DOWN: {
+                case ACTION_POINTER_DOWN: {
                     clear();
-                    transitionToState(STATE_MAGNIFIED_INTERACTION);
+                    transitionTo(STATE_PANNING_SCALING);
                 }
                 break;
-                case MotionEvent.ACTION_MOVE: {
+                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(eventX, eventY)) {
-                        if (mLastMoveOutsideMagnifiedRegion) {
-                            mLastMoveOutsideMagnifiedRegion = false;
-                            mMagnificationController.setCenter(eventX, eventY, true,
-                                    AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
-                        } else {
-                            mMagnificationController.setCenter(eventX, eventY, false,
-                                    AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
-                        }
+                        mMagnificationController.setCenter(eventX, eventY,
+                                /* animate */ mLastMoveOutsideMagnifiedRegion,
+                                AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
+                        mLastMoveOutsideMagnifiedRegion = false;
                     } else {
                         mLastMoveOutsideMagnifiedRegion = true;
                     }
                 }
                 break;
-                case MotionEvent.ACTION_UP: {
-                    if (!mTranslationEnabledBeforePan) {
-                        mMagnificationController.reset(true);
-                    }
+                case ACTION_UP: {
+                    if (!mZoomedInBeforeDrag) zoomOff();
                     clear();
-                    transitionToState(STATE_DETECTING);
+                    transitionTo(STATE_DETECTING);
                 }
                 break;
-                case MotionEvent.ACTION_POINTER_UP: {
+
+                case ACTION_DOWN:
+                case ACTION_POINTER_UP: {
                     throw new IllegalArgumentException(
-                            "Unexpected event type: ACTION_POINTER_UP");
+                            "Unexpected event type: " + MotionEvent.actionToString(action));
                 }
             }
         }
@@ -560,211 +610,224 @@
         public void clear() {
             mLastMoveOutsideMagnifiedRegion = false;
         }
+
+        @Override
+        public String toString() {
+            return "ViewportDraggingStateHandler{" +
+                    "mZoomedInBeforeDrag=" + mZoomedInBeforeDrag +
+                    ", mLastMoveOutsideMagnifiedRegion=" + mLastMoveOutsideMagnifiedRegion +
+                    '}';
+        }
     }
 
     /**
      * This class handles motion events when the event dispatch has not yet
      * determined what the user is doing. It watches for various tap events.
+     *
+     * @see #STATE_DETECTING
      */
-    private final class DetectingStateHandler implements MotionEventHandler {
+    final class DetectingStateHandler implements MotionEventHandler, Handler.Callback {
 
-        private static final int MESSAGE_ON_ACTION_TAP_AND_HOLD = 1;
-
+        private static final int MESSAGE_ON_TRIPLE_TAP_AND_HOLD = 1;
         private static final int MESSAGE_TRANSITION_TO_DELEGATING_STATE = 2;
 
-        private static final int ACTION_TAP_COUNT = 3;
-
-        private final int mTapTimeSlop = ViewConfiguration.getJumpTapTimeout();
-
-        private final int mMultiTapTimeSlop;
-
-        private final int mTapDistanceSlop;
-
-        private final int mMultiTapDistanceSlop;
+        final int mLongTapMinDelay = ViewConfiguration.getJumpTapTimeout();
+        final int mSwipeMinDistance;
+        final int mMultiTapMaxDelay;
+        final int mMultiTapMaxDistance;
 
         private MotionEventInfo mDelayedEventQueue;
+        MotionEvent mLastDown;
+        private MotionEvent mPreLastDown;
+        private MotionEvent mLastUp;
+        private MotionEvent mPreLastUp;
 
-        private MotionEvent mLastDownEvent;
-
-        private MotionEvent mLastTapUpEvent;
-
-        private int mTapCount;
+        Handler mHandler = new Handler(this);
 
         public DetectingStateHandler(Context context) {
-            mMultiTapTimeSlop = ViewConfiguration.getDoubleTapTimeout()
+            mMultiTapMaxDelay = ViewConfiguration.getDoubleTapTimeout()
                     + context.getResources().getInteger(
                     com.android.internal.R.integer.config_screen_magnification_multi_tap_adjustment);
-            mTapDistanceSlop = ViewConfiguration.get(context).getScaledTouchSlop();
-            mMultiTapDistanceSlop = ViewConfiguration.get(context).getScaledDoubleTapSlop();
+            mSwipeMinDistance = ViewConfiguration.get(context).getScaledTouchSlop();
+            mMultiTapMaxDistance = ViewConfiguration.get(context).getScaledDoubleTapSlop();
         }
 
-        private final Handler mHandler = new Handler() {
-            @Override
-            public void handleMessage(Message message) {
-                final int type = message.what;
-                switch (type) {
-                    case MESSAGE_ON_ACTION_TAP_AND_HOLD: {
-                        MotionEvent event = (MotionEvent) message.obj;
-                        final int policyFlags = message.arg1;
-                        onActionTapAndHold(event, policyFlags);
-                    }
-                    break;
-                    case MESSAGE_TRANSITION_TO_DELEGATING_STATE: {
-                        transitionToState(STATE_DELEGATING);
-                        sendDelayedMotionEvents();
-                        clear();
-                    }
-                    break;
-                    default: {
-                        throw new IllegalArgumentException("Unknown message type: " + type);
-                    }
+        @Override
+        public boolean handleMessage(Message message) {
+            final int type = message.what;
+            switch (type) {
+                case MESSAGE_ON_TRIPLE_TAP_AND_HOLD: {
+                    onTripleTapAndHold(/* down */ (MotionEvent) message.obj);
+                }
+                break;
+                case MESSAGE_TRANSITION_TO_DELEGATING_STATE: {
+                    transitionToDelegatingState(/* andClear */ true);
+                }
+                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);
-            final int action = event.getActionMasked();
-            switch (action) {
+            switch (event.getActionMasked()) {
                 case MotionEvent.ACTION_DOWN: {
+
                     mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE);
+
                     if (!mMagnificationController.magnificationRegionContains(
                             event.getX(), event.getY())) {
-                        transitionToDelegatingState(!mShortcutTriggered);
-                        return;
-                    }
-                    if (mShortcutTriggered) {
-                        Message message = mHandler.obtainMessage(MESSAGE_ON_ACTION_TAP_AND_HOLD,
-                                policyFlags, 0, event);
-                        mHandler.sendMessageDelayed(message,
-                                ViewConfiguration.getLongPressTimeout());
-                        return;
-                    }
-                    if (mDetectTripleTap) {
-                        if ((mTapCount == ACTION_TAP_COUNT - 1) && (mLastDownEvent != null)
-                                && GestureUtils.isMultiTap(mLastDownEvent, event, mMultiTapTimeSlop,
-                                        mMultiTapDistanceSlop, 0)) {
-                            Message message = mHandler.obtainMessage(MESSAGE_ON_ACTION_TAP_AND_HOLD,
-                                    policyFlags, 0, event);
-                            mHandler.sendMessageDelayed(message,
-                                    ViewConfiguration.getLongPressTimeout());
-                        } else if (mTapCount < ACTION_TAP_COUNT) {
-                            Message message = mHandler.obtainMessage(
-                                    MESSAGE_TRANSITION_TO_DELEGATING_STATE);
-                            mHandler.sendMessageDelayed(message, mMultiTapTimeSlop);
-                        }
-                        clearLastDownEvent();
-                        mLastDownEvent = MotionEvent.obtain(event);
-                    } else if (mMagnificationController.isMagnifying()) {
-                        // If magnified, consume an ACTION_DOWN until mMultiTapTimeSlop or
-                        // mTapDistanceSlop is reached to ensure MAGNIFIED_INTERACTION is reachable.
-                        Message message = mHandler.obtainMessage(
-                                MESSAGE_TRANSITION_TO_DELEGATING_STATE);
-                        mHandler.sendMessageDelayed(message, mMultiTapTimeSlop);
-                        return;
+
+                        transitionToDelegatingState(/* andClear */ !mShortcutTriggered);
+
+                    } else if (isMultiTapTriggered(2 /* taps */)) {
+
+                        // 3tap and hold
+                        delayedTransitionToDraggingState(event);
+
+                    } 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()) {
+
+                        delayedTransitionToDelegatingState();
+
                     } else {
-                        transitionToDelegatingState(true);
-                        return;
+
+                        // Delegate pending events without delay
+                        transitionToDelegatingState(/* andClear */ true);
                     }
                 }
                 break;
-                case MotionEvent.ACTION_POINTER_DOWN: {
+                case ACTION_POINTER_DOWN: {
                     if (mMagnificationController.isMagnifying()) {
-                        mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE);
-                        transitionToState(STATE_MAGNIFIED_INTERACTION);
+                        transitionTo(STATE_PANNING_SCALING);
                         clear();
                     } else {
-                        transitionToDelegatingState(true);
+                        transitionToDelegatingState(/* andClear */ true);
                     }
                 }
                 break;
-                case MotionEvent.ACTION_MOVE: {
-                    if (mLastDownEvent != null && mTapCount < ACTION_TAP_COUNT - 1) {
-                        final double distance = GestureUtils.computeDistance(mLastDownEvent,
-                                event, 0);
-                        if (Math.abs(distance) > mTapDistanceSlop) {
-                            transitionToDelegatingState(true);
-                        }
+                case ACTION_MOVE: {
+                    if (isFingerDown()
+                            && distance(mLastDown, /* move */ event) > mSwipeMinDistance
+                            // For convenience, viewport dragging on 3tap&hold takes precedence
+                            // over insta-delegating on 3tap&swipe
+                            // (which is a rare combo to be used aside from magnification)
+                            && !isMultiTapTriggered(2 /* taps */)) {
+
+                        // Swipe detected - delegate skipping timeout
+                        transitionToDelegatingState(/* andClear */ true);
                     }
                 }
                 break;
-                case MotionEvent.ACTION_UP: {
-                    mHandler.removeMessages(MESSAGE_ON_ACTION_TAP_AND_HOLD);
+                case ACTION_UP: {
+
+                    mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD);
+
                     if (!mMagnificationController.magnificationRegionContains(
                             event.getX(), event.getY())) {
-                        transitionToDelegatingState(!mShortcutTriggered);
-                        return;
+
+                        transitionToDelegatingState(/* andClear */ !mShortcutTriggered);
+
+                    } 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 */ event) >= mLongTapMinDelay)
+                                    || distance(mLastDown, /* mLastUp */ event)
+                                            >= mSwipeMinDistance) {
+
+                        transitionToDelegatingState(/* andClear */ true);
+
                     }
-                    if (mShortcutTriggered) {
-                        clear();
-                        onActionTap(event, policyFlags);
-                        return;
-                    }
-                    if (mLastDownEvent == null) {
-                        return;
-                    }
-                    if (!GestureUtils.isTap(mLastDownEvent, event, mTapTimeSlop,
-                            mTapDistanceSlop, 0)) {
-                        transitionToDelegatingState(true);
-                        return;
-                    }
-                    if (mLastTapUpEvent != null && !GestureUtils.isMultiTap(
-                            mLastTapUpEvent, event, mMultiTapTimeSlop, mMultiTapDistanceSlop, 0)) {
-                        transitionToDelegatingState(true);
-                        return;
-                    }
-                    mTapCount++;
-                    if (DEBUG_DETECTING) {
-                        Slog.i(LOG_TAG, "Tap count:" + mTapCount);
-                    }
-                    if (mTapCount == ACTION_TAP_COUNT) {
-                        clear();
-                        onActionTap(event, policyFlags);
-                        return;
-                    }
-                    clearLastTapUpEvent();
-                    mLastTapUpEvent = MotionEvent.obtain(event);
-                }
-                break;
-                case MotionEvent.ACTION_POINTER_UP: {
-                    /* do nothing */
                 }
                 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 #STATE_DELEGATING} */
+        public void delayedTransitionToDelegatingState() {
+            mHandler.sendEmptyMessageDelayed(
+                    MESSAGE_TRANSITION_TO_DELEGATING_STATE,
+                    mMultiTapMaxDelay);
+        }
+
+        /** -> {@link #STATE_VIEWPORT_DRAGGING} */
+        public void delayedTransitionToDraggingState(MotionEvent event) {
+            mHandler.sendMessageDelayed(
+                    mHandler.obtainMessage(MESSAGE_ON_TRIPLE_TAP_AND_HOLD, event),
+                    ViewConfiguration.getLongPressTimeout());
+        }
+
         @Override
         public void clear() {
-            setMagnificationShortcutTriggered(false);
-            mHandler.removeMessages(MESSAGE_ON_ACTION_TAP_AND_HOLD);
+            setShortcutTriggered(false);
+            mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD);
             mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE);
-            clearTapDetectionState();
             clearDelayedMotionEvents();
         }
 
-        private void clearTapDetectionState() {
-            mTapCount = 0;
-            clearLastTapUpEvent();
-            clearLastDownEvent();
-        }
-
-        private void clearLastTapUpEvent() {
-            if (mLastTapUpEvent != null) {
-                mLastTapUpEvent.recycle();
-                mLastTapUpEvent = null;
-            }
-        }
-
-        private void clearLastDownEvent() {
-            if (mLastDownEvent != null) {
-                mLastDownEvent.recycle();
-                mLastDownEvent = null;
-            }
-        }
 
         private void cacheDelayedMotionEvent(MotionEvent event, MotionEvent rawEvent,
                 int policyFlags) {
+            if (event.getActionMasked() == ACTION_DOWN) {
+                mPreLastDown = mLastDown;
+                mLastDown = event;
+            } else if (event.getActionMasked() == ACTION_UP) {
+                mPreLastUp = mLastUp;
+                mLastUp = event;
+            }
+
             MotionEventInfo info = MotionEventInfo.obtain(event, rawEvent,
                     policyFlags);
             if (mDelayedEventQueue == null) {
@@ -782,8 +845,13 @@
             while (mDelayedEventQueue != null) {
                 MotionEventInfo info = mDelayedEventQueue;
                 mDelayedEventQueue = info.mNext;
-                MagnificationGestureHandler.this.onMotionEvent(info.mEvent, info.mRawEvent,
-                        info.mPolicyFlags);
+
+                // Because MagnifiedInteractionStateHandler requires well-formed event stream
+                mPanningScalingStateHandler.onMotionEvent(
+                        info.event, info.rawEvent, info.policyFlags);
+
+                delegateEvent(info.event, info.rawEvent, info.policyFlags);
+
                 info.recycle();
             }
         }
@@ -794,91 +862,136 @@
                 mDelayedEventQueue = info.mNext;
                 info.recycle();
             }
+            mPreLastDown = null;
+            mPreLastUp = null;
+            mLastDown = null;
+            mLastUp = null;
         }
 
-        private void transitionToDelegatingState(boolean andClear) {
-            transitionToState(STATE_DELEGATING);
+        void transitionToDelegatingState(boolean andClear) {
+            transitionTo(STATE_DELEGATING);
             sendDelayedMotionEvents();
-            if (andClear) {
-                clear();
-            }
+            if (andClear) clear();
         }
 
-        private void onActionTap(MotionEvent up, int policyFlags) {
+        private void onTripleTap(MotionEvent up) {
+
             if (DEBUG_DETECTING) {
-                Slog.i(LOG_TAG, "onActionTap()");
+                Slog.i(LOG_TAG, "onTripleTap(); delayed: "
+                        + MotionEventInfo.toString(mDelayedEventQueue));
             }
-
-            if (!mMagnificationController.isMagnifying()) {
-                final float targetScale = mMagnificationController.getPersistedScale();
-                final float scale = MathUtils.constrain(targetScale, MIN_SCALE, MAX_SCALE);
-                mMagnificationController.setScaleAndCenter(scale, up.getX(), up.getY(), true,
-                        AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
-            } else {
-                mMagnificationController.reset(true);
-            }
-        }
-
-        private void onActionTapAndHold(MotionEvent down, int policyFlags) {
-            if (DEBUG_DETECTING) {
-                Slog.i(LOG_TAG, "onActionTapAndHold()");
-            }
-
             clear();
-            mTranslationEnabledBeforePan = mMagnificationController.isMagnifying();
 
-            final float targetScale = mMagnificationController.getPersistedScale();
-            final float scale = MathUtils.constrain(targetScale, MIN_SCALE, MAX_SCALE);
-            mMagnificationController.setScaleAndCenter(scale, down.getX(), down.getY(), true,
-                    AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
-
-            transitionToState(STATE_VIEWPORT_DRAGGING);
+            // Toggle zoom
+            if (mMagnificationController.isMagnifying()) {
+                zoomOff();
+            } else {
+                zoomOn(up.getX(), up.getY());
+            }
         }
+
+        void onTripleTapAndHold(MotionEvent down) {
+
+            if (DEBUG_DETECTING) Slog.i(LOG_TAG, "onTripleTapAndHold()");
+            clear();
+
+            mViewportDraggingStateHandler.mZoomedInBeforeDrag =
+                    mMagnificationController.isMagnifying();
+
+            zoomOn(down.getX(), down.getY());
+
+            transitionTo(STATE_VIEWPORT_DRAGGING);
+        }
+
+        @Override
+        public String toString() {
+            return "DetectingStateHandler{" +
+                    "tapCount()=" + tapCount() +
+                    ", mDelayedEventQueue=" + MotionEventInfo.toString(mDelayedEventQueue) +
+                    '}';
+        }
+    }
+
+    private void zoomOn(float centerX, float centerY) {
+        final float scale = MathUtils.constrain(
+                mMagnificationController.getPersistedScale(),
+                MIN_SCALE, MAX_SCALE);
+        mMagnificationController.setScaleAndCenter(
+                scale, centerX, centerY,
+                /* animate */ true,
+                AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID);
+    }
+
+    private void zoomOff() {
+        mMagnificationController.reset(/* animate */ true);
+    }
+
+    private static MotionEvent recycleAndNullify(@Nullable MotionEvent event) {
+        if (event != null) {
+            event.recycle();
+        }
+        return null;
+    }
+
+    @Override
+    public String toString() {
+        return "MagnificationGestureHandler{" +
+                "mDetectingStateHandler=" + mDetectingStateHandler +
+                ", mMagnifiedInteractionStateHandler=" + mPanningScalingStateHandler +
+                ", mViewportDraggingStateHandler=" + mViewportDraggingStateHandler +
+                ", mDetectTripleTap=" + mDetectTripleTap +
+                ", mDetectShortcutTrigger=" + mDetectShortcutTrigger +
+                ", mCurrentState=" + stateToString(mCurrentState) +
+                ", mPreviousState=" + stateToString(mPreviousState) +
+                ", mShortcutTriggered=" + mShortcutTriggered +
+                ", mDelegatingStateDownTime=" + mDelegatingStateDownTime +
+                ", mMagnificationController=" + mMagnificationController +
+                '}';
     }
 
     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 mEvent;
-
-        public MotionEvent mRawEvent;
-
-        public int mPolicyFlags;
+        public MotionEvent event;
+        public MotionEvent rawEvent;
+        public int policyFlags;
 
         public static MotionEventInfo obtain(MotionEvent event, MotionEvent rawEvent,
                 int policyFlags) {
             synchronized (sLock) {
-                MotionEventInfo info;
-                if (sPoolSize > 0) {
-                    sPoolSize--;
-                    info = sPool;
-                    sPool = info.mNext;
-                    info.mNext = null;
-                    info.mInPool = false;
-                } else {
-                    info = new MotionEventInfo();
-                }
+                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) {
-            mEvent = MotionEvent.obtain(event);
-            mRawEvent = MotionEvent.obtain(rawEvent);
-            mPolicyFlags = policyFlags;
+            this.event = MotionEvent.obtain(event);
+            this.rawEvent = MotionEvent.obtain(rawEvent);
+            this.policyFlags = policyFlags;
         }
 
         public void recycle() {
@@ -897,11 +1010,22 @@
         }
 
         private void clear() {
-            mEvent.recycle();
-            mEvent = null;
-            mRawEvent.recycle();
-            mRawEvent = null;
-            mPolicyFlags = 0;
+            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);
         }
     }
 
@@ -927,7 +1051,7 @@
 
         @Override
         public void onReceive(Context context, Intent intent) {
-            mGestureHandler.setMagnificationShortcutTriggered(false);
+            mGestureHandler.setShortcutTriggered(false);
         }
     }
 }
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/MagnificationGestureHandlerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/MagnificationGestureHandlerTest.java
new file mode 100644
index 0000000..50824e3
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/accessibility/MagnificationGestureHandlerTest.java
@@ -0,0 +1,535 @@
+/*
+ * 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 com.android.server.accessibility;
+
+import static android.util.ExceptionUtils.propagate;
+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 com.android.server.testutils.TestUtils.strictMock;
+
+import static org.junit.Assert.fail;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import android.annotation.NonNull;
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.runner.AndroidJUnit4;
+import android.util.DebugUtils;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+
+import com.android.server.testutils.OffsettableClock;
+import com.android.server.testutils.TestHandler;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.function.IntConsumer;
+
+
+@RunWith(AndroidJUnit4.class)
+public class MagnificationGestureHandlerTest {
+
+    public static final int STATE_IDLE = 1;
+    public static final int STATE_ZOOMED = 2;
+    public static final int STATE_2TAPS = 3;
+    public static final int STATE_ZOOMED_2TAPS = 4;
+    public static final int STATE_SHORTCUT_TRIGGERED = 5;
+    public static final int STATE_DRAGGING_TMP = 6;
+    public static final int STATE_DRAGGING = 7;
+    public static final int STATE_PANNING = 8;
+    public static final int STATE_SCALING_AND_PANNING = 9;
+
+
+    public static final int FIRST_STATE = STATE_IDLE;
+    public static final int LAST_STATE = STATE_SCALING_AND_PANNING;
+
+    // Co-prime x and y, to potentially catch x-y-swapped errors
+    public static final float DEFAULT_X = 301;
+    public static final float DEFAULT_Y = 299;
+
+    private Context mContext;
+    private AccessibilityManagerService mAms;
+    private MagnificationController mMagnificationController;
+    private OffsettableClock mClock;
+    private MagnificationGestureHandler mMgh;
+    private TestHandler mHandler;
+
+    @Before
+    public void setUp() {
+        mContext = InstrumentationRegistry.getContext();
+        mAms = new AccessibilityManagerService(mContext);
+        mMagnificationController = new MagnificationController(
+                mContext, mAms, /* lock */ new Object()) {
+            @Override
+            public boolean magnificationRegionContains(float x, float y) {
+                return true;
+            }
+
+            @Override
+            void setForceShowMagnifiableBounds(boolean show) {}
+        };
+        mMagnificationController.mRegistered = true;
+        mClock = new OffsettableClock.Stopped();
+
+        boolean detectTripleTap = true;
+        boolean detectShortcutTrigger = true;
+        mMgh = newInstance(detectTripleTap, detectShortcutTrigger);
+    }
+
+    @NonNull
+    public MagnificationGestureHandler newInstance(boolean detectTripleTap,
+            boolean detectShortcutTrigger) {
+        MagnificationGestureHandler h = new MagnificationGestureHandler(
+                mContext, mMagnificationController,
+                detectTripleTap, detectShortcutTrigger);
+        mHandler = new TestHandler(h.mDetectingStateHandler, mClock);
+        h.mDetectingStateHandler.mHandler = mHandler;
+        h.setNext(strictMock(EventStreamTransformation.class));
+        return h;
+    }
+
+    @Test
+    public void testInitialState_isIdle() {
+        assertIn(STATE_IDLE);
+    }
+
+    /**
+     * Covers paths to get to and back between each state and {@link #STATE_IDLE}
+     * This navigates between states using "canonical" paths, specified in
+     * {@link #goFromStateIdleTo} (for traversing away from {@link #STATE_IDLE}) and
+     * {@link #returnToNormalFrom} (for navigating back to {@link #STATE_IDLE})
+     */
+    @Test
+    public void testEachState_isReachableAndRecoverable() {
+        forEachState(state -> {
+            goFromStateIdleTo(state);
+            assertIn(state);
+
+            returnToNormalFrom(state);
+            try {
+                assertIn(STATE_IDLE);
+            } catch (AssertionError e) {
+                throw new AssertionError("Failed while testing state " + stateToString(state), e);
+            }
+        });
+    }
+
+    @Test
+    public void testStates_areMutuallyExclusive() {
+        forEachState(state1 -> {
+            forEachState(state2 -> {
+                if (state1 < state2) {
+                    goFromStateIdleTo(state1);
+                    try {
+                        assertIn(state2);
+                        fail("State " + stateToString(state1) + " also implies state "
+                                + stateToString(state2) + stateDump());
+                    } catch (AssertionError e) {
+                        // expected
+                        returnToNormalFrom(state1);
+                    }
+                }
+            });
+        });
+    }
+
+    /**
+     * Covers edges of the graph not covered by "canonical" transitions specified in
+     * {@link #goFromStateIdleTo} and {@link #returnToNormalFrom}
+     */
+    @SuppressWarnings("Convert2MethodRef")
+    @Test
+    public void testAlternativeTransitions_areWorking() {
+        // A11y button followed by a tap&hold turns temporary "viewport dragging" zoom on
+        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> {
+            send(downEvent());
+            fastForward1sec();
+        }, STATE_DRAGGING_TMP);
+
+        // A11y button followed by a tap turns zoom on
+        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> tap(), STATE_ZOOMED);
+
+        // A11y button pressed second time negates the 1st press
+        assertTransition(STATE_SHORTCUT_TRIGGERED, () -> triggerShortcut(), STATE_IDLE);
+
+        // A11y button turns zoom off
+        assertTransition(STATE_ZOOMED, () -> triggerShortcut(), STATE_IDLE);
+
+
+        // Double tap times out while zoomed
+        assertTransition(STATE_ZOOMED_2TAPS, () -> {
+            allowEventDelegation();
+            fastForward1sec();
+        }, STATE_ZOOMED);
+
+        // tap+tap+swipe gets delegated
+        assertTransition(STATE_2TAPS, () -> {
+            allowEventDelegation();
+            swipe();
+        }, STATE_IDLE);
+    }
+
+    @Test
+    public void testNonTransitions_dontChangeState() {
+        // ACTION_POINTER_DOWN triggers event delegation if not magnifying
+        assertStaysIn(STATE_IDLE, () -> {
+            allowEventDelegation();
+            send(downEvent());
+            send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y));
+        });
+
+        // Long tap breaks the triple-tap detection sequence
+        Runnable tapAndLongTap = () -> {
+            allowEventDelegation();
+            tap();
+            longTap();
+        };
+        assertStaysIn(STATE_IDLE, tapAndLongTap);
+        assertStaysIn(STATE_ZOOMED, tapAndLongTap);
+
+        // Triple tap with delays in between doesn't count
+        Runnable slow3tap = () -> {
+            tap();
+            fastForward1sec();
+            tap();
+            fastForward1sec();
+            tap();
+        };
+        assertStaysIn(STATE_IDLE, slow3tap);
+        assertStaysIn(STATE_ZOOMED, slow3tap);
+    }
+
+    @Test
+    public void testDisablingTripleTap_removesInputLag() {
+        mMgh = newInstance(/* detect3tap */ false, /* detectShortcut */ true);
+        goFromStateIdleTo(STATE_IDLE);
+        allowEventDelegation();
+        tap();
+        // no fast forward
+        verify(mMgh.mNext, times(2)).onMotionEvent(any(), any(), anyInt());
+    }
+
+    private void assertTransition(int fromState, Runnable transitionAction, int toState) {
+        goFromStateIdleTo(fromState);
+        transitionAction.run();
+        assertIn(toState);
+        returnToNormalFrom(toState);
+    }
+
+    private void assertStaysIn(int state, Runnable action) {
+        assertTransition(state, action, state);
+    }
+
+    private void forEachState(IntConsumer action) {
+        for (int state = FIRST_STATE; state <= LAST_STATE; state++) {
+            action.accept(state);
+        }
+    }
+
+    private void allowEventDelegation() {
+        doNothing().when(mMgh.mNext).onMotionEvent(any(), any(), anyInt());
+    }
+
+    private void fastForward1sec() {
+        fastForward(1000);
+    }
+
+    private void fastForward(int ms) {
+        mClock.fastForward(ms);
+        mHandler.timeAdvance();
+    }
+
+    /**
+     * Asserts that {@link #mMgh the handler} is in the given {@code state}
+     */
+    private void assertIn(int state) {
+        switch (state) {
+
+            // Asserts on separate lines for accurate stack traces
+
+            case STATE_IDLE: {
+                check(tapCount() < 2, state);
+                check(!mMgh.mShortcutTriggered, state);
+                check(!isZoomed(), state);
+            } break;
+            case STATE_ZOOMED: {
+                check(isZoomed(), state);
+                check(tapCount() < 2, state);
+            } break;
+            case STATE_2TAPS: {
+                check(!isZoomed(), state);
+                check(tapCount() == 2, state);
+            } break;
+            case STATE_ZOOMED_2TAPS: {
+                check(isZoomed(), state);
+                check(tapCount() == 2, state);
+            } break;
+            case STATE_DRAGGING: {
+                check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_VIEWPORT_DRAGGING,
+                        state);
+                check(mMgh.mViewportDraggingStateHandler.mZoomedInBeforeDrag, state);
+            } break;
+            case STATE_DRAGGING_TMP: {
+                check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_VIEWPORT_DRAGGING,
+                        state);
+                check(!mMgh.mViewportDraggingStateHandler.mZoomedInBeforeDrag, state);
+            } break;
+            case STATE_SHORTCUT_TRIGGERED: {
+                check(mMgh.mShortcutTriggered, state);
+                check(!isZoomed(), state);
+            } break;
+            case STATE_PANNING: {
+                check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_PANNING_SCALING,
+                        state);
+                check(!mMgh.mPanningScalingStateHandler.mScaling, state);
+            } break;
+            case STATE_SCALING_AND_PANNING: {
+                check(mMgh.mCurrentState == MagnificationGestureHandler.STATE_PANNING_SCALING,
+                        state);
+                check(mMgh.mPanningScalingStateHandler.mScaling, state);
+            } break;
+            default: throw new IllegalArgumentException("Illegal state: " + state);
+        }
+    }
+
+    /**
+     * Defines a "canonical" path from {@link #STATE_IDLE} to {@code state}
+     */
+    private void goFromStateIdleTo(int state) {
+        try {
+            switch (state) {
+                case STATE_IDLE: {
+                    mMgh.clearAndTransitionToStateDetecting();
+                } break;
+                case STATE_2TAPS: {
+                    goFromStateIdleTo(STATE_IDLE);
+                    tap();
+                    tap();
+                } break;
+                case STATE_ZOOMED: {
+                    if (mMgh.mDetectTripleTap) {
+                        goFromStateIdleTo(STATE_2TAPS);
+                        tap();
+                    } else {
+                        goFromStateIdleTo(STATE_SHORTCUT_TRIGGERED);
+                        tap();
+                    }
+                } break;
+                case STATE_ZOOMED_2TAPS: {
+                    goFromStateIdleTo(STATE_ZOOMED);
+                    tap();
+                    tap();
+                } break;
+                case STATE_DRAGGING: {
+                    goFromStateIdleTo(STATE_ZOOMED_2TAPS);
+                    send(downEvent());
+                    fastForward1sec();
+                } break;
+                case STATE_DRAGGING_TMP: {
+                    goFromStateIdleTo(STATE_2TAPS);
+                    send(downEvent());
+                    fastForward1sec();
+                } break;
+                case STATE_SHORTCUT_TRIGGERED: {
+                    goFromStateIdleTo(STATE_IDLE);
+                    triggerShortcut();
+                } break;
+                case STATE_PANNING: {
+                    goFromStateIdleTo(STATE_ZOOMED);
+                    send(downEvent());
+                    send(pointerEvent(ACTION_POINTER_DOWN, DEFAULT_X * 2, DEFAULT_Y));
+                } break;
+                case STATE_SCALING_AND_PANNING: {
+                    goFromStateIdleTo(STATE_PANNING);
+                    send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 3));
+                    send(pointerEvent(ACTION_MOVE, DEFAULT_X * 2, DEFAULT_Y * 4));
+                } break;
+                default:
+                    throw new IllegalArgumentException("Illegal state: " + state);
+            }
+        } catch (Throwable t) {
+            throw new RuntimeException("Failed to go to state " + stateToString(state), t);
+        }
+    }
+
+    /**
+     * Defines a "canonical" path from {@code state} to {@link #STATE_IDLE}
+     */
+    private void returnToNormalFrom(int state) {
+        switch (state) {
+            case STATE_IDLE: {
+                // no op
+            } break;
+            case STATE_2TAPS: {
+                allowEventDelegation();
+                fastForward1sec();
+            } break;
+            case STATE_ZOOMED: {
+                if (mMgh.mDetectTripleTap) {
+                    tap();
+                    tap();
+                    returnToNormalFrom(STATE_ZOOMED_2TAPS);
+                } else {
+                    triggerShortcut();
+                }
+            } break;
+            case STATE_ZOOMED_2TAPS: {
+                tap();
+            } break;
+            case STATE_DRAGGING: {
+                send(upEvent());
+                returnToNormalFrom(STATE_ZOOMED);
+            } break;
+            case STATE_DRAGGING_TMP: {
+                send(upEvent());
+            } break;
+            case STATE_SHORTCUT_TRIGGERED: {
+                triggerShortcut();
+            } break;
+            case STATE_PANNING: {
+                send(pointerEvent(ACTION_POINTER_UP, DEFAULT_X * 2, DEFAULT_Y));
+                send(upEvent());
+                returnToNormalFrom(STATE_ZOOMED);
+            } break;
+            case STATE_SCALING_AND_PANNING: {
+                returnToNormalFrom(STATE_PANNING);
+            } break;
+            default: throw new IllegalArgumentException("Illegal state: " + state);
+        }
+    }
+
+    private void check(boolean condition, int expectedState) {
+        if (!condition) {
+            fail("Expected to be in state " + stateToString(expectedState) + stateDump());
+        }
+    }
+
+    private boolean isZoomed() {
+        return mMgh.mMagnificationController.isMagnifying();
+    }
+
+    private int tapCount() {
+        return mMgh.mDetectingStateHandler.tapCount();
+    }
+
+    private static String stateToString(int state) {
+        return DebugUtils.valueToString(MagnificationGestureHandlerTest.class, "STATE_", state);
+    }
+
+    private void tap() {
+        MotionEvent downEvent = downEvent();
+        send(downEvent);
+        send(upEvent(downEvent.getDownTime()));
+    }
+
+    private void swipe() {
+        MotionEvent downEvent = downEvent();
+        send(downEvent);
+        send(moveEvent(DEFAULT_X * 2, DEFAULT_Y * 2));
+        send(upEvent(downEvent.getDownTime()));
+    }
+
+    private void longTap() {
+        MotionEvent downEvent = downEvent();
+        send(downEvent);
+        fastForward(2000);
+        send(upEvent(downEvent.getDownTime()));
+    }
+
+    private void triggerShortcut() {
+        mMgh.notifyShortcutTriggered();
+    }
+
+    private void send(MotionEvent event) {
+        event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+        try {
+            mMgh.onMotionEvent(event, event, /* policyFlags */ 0);
+        } catch (Throwable t) {
+            throw new RuntimeException("Exception while handling " + event, t);
+        }
+        fastForward(1);
+    }
+
+    private MotionEvent moveEvent(float x, float y) {
+        return MotionEvent.obtain(defaultDownTime(), mClock.now(), ACTION_MOVE, x, y, 0);
+    }
+
+    private MotionEvent downEvent() {
+        return MotionEvent.obtain(mClock.now(), mClock.now(),
+                ACTION_DOWN, DEFAULT_X, DEFAULT_Y, 0);
+    }
+
+    private MotionEvent upEvent() {
+        return upEvent(defaultDownTime());
+    }
+
+    private MotionEvent upEvent(long downTime) {
+        return MotionEvent.obtain(downTime, mClock.now(),
+                MotionEvent.ACTION_UP, DEFAULT_X, DEFAULT_Y, 0);
+    }
+
+    private long defaultDownTime() {
+        MotionEvent lastDown = mMgh.mDetectingStateHandler.mLastDown;
+        return lastDown == null ? mClock.now() - 1 : lastDown.getDownTime();
+    }
+
+    private MotionEvent pointerEvent(int action, float x, float y) {
+        MotionEvent.PointerProperties defPointerProperties = new MotionEvent.PointerProperties();
+        defPointerProperties.id = 0;
+        defPointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER;
+        MotionEvent.PointerProperties pointerProperties = new MotionEvent.PointerProperties();
+        pointerProperties.id = 1;
+        pointerProperties.toolType = MotionEvent.TOOL_TYPE_FINGER;
+
+        MotionEvent.PointerCoords defPointerCoords = new MotionEvent.PointerCoords();
+        defPointerCoords.x = DEFAULT_X;
+        defPointerCoords.y = DEFAULT_Y;
+        MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords();
+        pointerCoords.x = x;
+        pointerCoords.y = y;
+
+        return MotionEvent.obtain(
+            /* downTime */ mClock.now(),
+            /* eventTime */ mClock.now(),
+            /* action */ action,
+            /* pointerCount */ 2,
+            /* pointerProperties */ new MotionEvent.PointerProperties[] {
+                        defPointerProperties, pointerProperties },
+            /* pointerCoords */ new MotionEvent.PointerCoords[] { defPointerCoords, pointerCoords },
+            /* metaState */ 0,
+            /* buttonState */ 0,
+            /* xPrecision */ 1.0f,
+            /* yPrecision */ 1.0f,
+            /* deviceId */ 0,
+            /* edgeFlags */ 0,
+            /* source */ InputDevice.SOURCE_TOUCHSCREEN,
+            /* flags */ 0);
+    }
+
+    private String stateDump() {
+        return "\nCurrent state dump:\n" + mMgh + "\n" + mHandler.getPendingMessages();
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java b/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java
index 04c0251..bc16297 100644
--- a/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java
+++ b/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java
@@ -16,7 +16,7 @@
 
 package com.android.server.backup;
 
-import static com.android.server.testutis.TestUtils.assertExpectException;
+import static com.android.server.testutils.TestUtils.assertExpectException;
 
 import static com.google.common.truth.Truth.assertThat;
 
diff --git a/services/tests/servicestests/src/com/android/server/devicepolicy/DevicePolicyManagerTest.java b/services/tests/servicestests/src/com/android/server/devicepolicy/DevicePolicyManagerTest.java
index e3faa52..0fda0fe 100644
--- a/services/tests/servicestests/src/com/android/server/devicepolicy/DevicePolicyManagerTest.java
+++ b/services/tests/servicestests/src/com/android/server/devicepolicy/DevicePolicyManagerTest.java
@@ -22,7 +22,7 @@
 import static android.os.UserManagerInternal.CAMERA_DISABLED_LOCALLY;
 import static android.os.UserManagerInternal.CAMERA_NOT_DISABLED;
 
-import static com.android.server.testutis.TestUtils.assertExpectException;
+import static com.android.server.testutils.TestUtils.assertExpectException;
 
 import static org.mockito.Matchers.any;
 import static org.mockito.Matchers.anyInt;
@@ -54,7 +54,6 @@
 import android.app.admin.PasswordMetrics;
 import android.content.BroadcastReceiver;
 import android.content.ComponentName;
-import android.content.Context;
 import android.content.Intent;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageInfo;
diff --git a/services/tests/servicestests/src/com/android/server/testutils/OffsettableClock.java b/services/tests/servicestests/src/com/android/server/testutils/OffsettableClock.java
new file mode 100644
index 0000000..8dabbc4
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/testutils/OffsettableClock.java
@@ -0,0 +1,79 @@
+/*
+ * 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 com.android.server.testutils;
+
+import android.os.SystemClock;
+
+import java.util.function.LongSupplier;
+
+/**
+ * A time supplier (in the format of a {@code long} as the amount of milliseconds) similar
+ * to {@link SystemClock#uptimeMillis()}, but with the ability to {@link #fastForward}
+ * and {@link #rewind}
+ *
+ * Implements {@link LongSupplier} to be interchangeable with {@code SystemClock::uptimeMillis}
+ *
+ * Can be provided to {@link TestHandler} to "mock time" for the delayed execution testing
+ *
+ * @see OffsettableClock.Stopped for a version of this clock that does not advance on its own
+ */
+public class OffsettableClock implements LongSupplier {
+    private long mOffset = 0L;
+
+    /**
+     * @return Current time in milliseconds, according to this clock
+     */
+    public long now() {
+        return realNow() + mOffset;
+    }
+
+    /**
+     * Can be overriden with a constant for a clock that stands still, and is only ever moved
+     * manually
+     */
+    public long realNow() {
+        return SystemClock.uptimeMillis();
+    }
+
+    public void fastForward(long timeMs) {
+        mOffset += timeMs;
+    }
+    public void rewind(long timeMs) {
+        fastForward(-timeMs);
+    }
+    public void reset() {
+        mOffset = 0;
+    }
+
+    /** @deprecated Only present for {@link LongSupplier} contract */
+    @Override
+    @Deprecated
+    public long getAsLong() {
+        return now();
+    }
+
+    /**
+     * An {@link OffsettableClock} that does not advance with real time, and can only be
+     * advanced manually via {@link #fastForward}
+     */
+    public static class Stopped extends OffsettableClock {
+        @Override
+        public long realNow() {
+            return 0L;
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/testutils/TestHandler.java b/services/tests/servicestests/src/com/android/server/testutils/TestHandler.java
new file mode 100644
index 0000000..2d4bc0f
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/testutils/TestHandler.java
@@ -0,0 +1,156 @@
+/*
+ * 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 com.android.server.testutils;
+
+
+import static android.util.ExceptionUtils.getRootCause;
+import static android.util.ExceptionUtils.propagate;
+
+import android.os.Handler;
+import android.os.Message;
+import android.os.SystemClock;
+import android.util.ArrayMap;
+
+import java.util.Map;
+import java.util.PriorityQueue;
+import java.util.function.LongSupplier;
+
+/**
+ * A test {@link Handler} that stores incoming {@link Message}s and {@link Runnable callbacks}
+ * in a {@link PriorityQueue} based on time, to be manually processed later in a correct order
+ * either all together with {@link #flush}, or only those due at the current time with
+ * {@link #timeAdvance}.
+ *
+ * For the latter use case this also supports providing a custom clock (in a format of a
+ * milliseconds-returning {@link LongSupplier}), that will be used for storing the messages'
+ * timestamps to be posted at, and checked against during {@link #timeAdvance}.
+ *
+ * This allows to test code that uses {@link Handler}'s delayed invocation capabilities, such as
+ * {@link Handler#sendMessageDelayed} or {@link Handler#postDelayed} without resorting to
+ * synchronously {@link Thread#sleep}ing in your test.
+ *
+ * @see OffsettableClock for a useful custom clock implementation to use with this handler
+ */
+public class TestHandler extends Handler {
+    private static final LongSupplier DEFAULT_CLOCK = SystemClock::uptimeMillis;
+
+    private final PriorityQueue<MsgInfo> mMessages = new PriorityQueue<>();
+    /**
+     * Map of: {@code message id -> count of such messages currently pending }
+     */
+    // Boxing is ok here - both msg ids and their pending counts tend to be well below 128
+    private final Map<Integer, Integer> mPendingMsgTypeCounts = new ArrayMap<>();
+    private final LongSupplier mClock;
+
+    public TestHandler(Callback callback) {
+        this(callback, DEFAULT_CLOCK);
+    }
+
+    public TestHandler(Callback callback, LongSupplier clock) {
+        super(callback);
+        mClock = clock;
+    }
+
+    @Override
+    public boolean sendMessageAtTime(Message msg, long uptimeMillis) {
+        mPendingMsgTypeCounts.put(msg.what,
+                mPendingMsgTypeCounts.getOrDefault(msg.what, 0) + 1);
+
+        // uptimeMillis is an absolute time obtained as SystemClock.uptimeMillis() + offsetMillis
+        // if custom clock is given, recalculate the time with regards to it
+        if (mClock != DEFAULT_CLOCK) {
+            uptimeMillis = uptimeMillis - SystemClock.uptimeMillis() + mClock.getAsLong();
+        }
+
+        // post a dummy queue entry to keep track of message removal
+        return super.sendMessageAtTime(msg, Long.MAX_VALUE)
+                && mMessages.add(new MsgInfo(Message.obtain(msg), uptimeMillis));
+    }
+
+    /** @see TestHandler */
+    public void timeAdvance() {
+        long now = mClock.getAsLong();
+        while (!mMessages.isEmpty() && mMessages.peek().sendTime <= now) {
+            dispatch(mMessages.poll());
+        }
+    }
+
+    /**
+     * Dispatch all messages in order
+     *
+     * @see TestHandler
+     */
+    public void flush() {
+        MsgInfo msg;
+        while ((msg = mMessages.poll()) != null) {
+            dispatch(msg);
+        }
+    }
+
+    public PriorityQueue<MsgInfo> getPendingMessages() {
+        return new PriorityQueue<>(mMessages);
+    }
+
+    private void dispatch(MsgInfo msg) {
+        int msgId = msg.message.what;
+
+        if (!hasMessages(msgId)) {
+            // Handler.removeMessages(msgId) must have been called
+            return;
+        }
+
+        try {
+            Integer pendingMsgCount = mPendingMsgTypeCounts.getOrDefault(msgId, 0);
+            if (pendingMsgCount <= 1) {
+                removeMessages(msgId);
+            }
+            mPendingMsgTypeCounts.put(msgId, pendingMsgCount - 1);
+
+            dispatchMessage(msg.message);
+        } catch (Throwable t) {
+            // Append stack trace of this message being posted as a cause for a helpful
+            // test error message
+            throw propagate(getRootCause(t).initCause(msg.postPoint));
+        } finally {
+            msg.message.recycle();
+        }
+    }
+
+    private class MsgInfo implements Comparable<MsgInfo> {
+        public final Message message;
+        public final long sendTime;
+        public final RuntimeException postPoint;
+
+        private MsgInfo(Message message, long sendTime) {
+            this.message = message;
+            this.sendTime = sendTime;
+            this.postPoint = new RuntimeException("Message originated from here:");
+        }
+
+        @Override
+        public int compareTo(MsgInfo o) {
+            return (int) (sendTime - o.sendTime);
+        }
+
+        @Override
+        public String toString() {
+            return "MsgInfo{" +
+                    "message=" + message +
+                    ", sendTime=" + sendTime +
+                    '}';
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/testutis/TestUtils.java b/services/tests/servicestests/src/com/android/server/testutils/TestUtils.java
similarity index 73%
rename from services/tests/servicestests/src/com/android/server/testutis/TestUtils.java
rename to services/tests/servicestests/src/com/android/server/testutils/TestUtils.java
index 8828988..b200293 100644
--- a/services/tests/servicestests/src/com/android/server/testutis/TestUtils.java
+++ b/services/tests/servicestests/src/com/android/server/testutils/TestUtils.java
@@ -13,12 +13,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package com.android.server.testutis;
+package com.android.server.testutils;
 
 import android.test.MoreAsserts;
 
 import junit.framework.Assert;
 
+import org.mockito.Mockito;
+import org.mockito.stubbing.Answer;
+
 public class TestUtils {
     private TestUtils() {
     }
@@ -44,4 +47,17 @@
         Assert.fail("Expected exception type " + expectedExceptionType.getName()
                 + " was not thrown");
     }
+
+    /**
+     * EasyMock-style "strict" mock that throws immediately on any interaction that was not
+     * explicitly allowed.
+     *
+     * You can allow certain method calls on a whitelist basis by stubbing them e.g. with
+     * {@link Mockito#doAnswer}, {@link Mockito#doNothing}, etc.
+     */
+    public static <T> T strictMock(Class<T> c) {
+        return Mockito.mock(c, (Answer) invocation -> {
+            throw new AssertionError("Unexpected invocation: " + invocation);
+        });
+    }
 }