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