| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package com.android.server.accessibility; |
| |
| import static android.view.InputDevice.SOURCE_TOUCHSCREEN; |
| import static android.view.MotionEvent.ACTION_DOWN; |
| import static android.view.MotionEvent.ACTION_MOVE; |
| import static android.view.MotionEvent.ACTION_POINTER_DOWN; |
| import static android.view.MotionEvent.ACTION_POINTER_UP; |
| import static android.view.MotionEvent.ACTION_UP; |
| |
| import static com.android.server.accessibility.GestureUtils.distance; |
| |
| import static java.lang.Math.abs; |
| import static java.util.Arrays.asList; |
| import static java.util.Arrays.copyOfRange; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.content.BroadcastReceiver; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.os.Handler; |
| import android.os.Message; |
| import android.util.MathUtils; |
| import android.util.Slog; |
| import android.util.TypedValue; |
| import android.view.GestureDetector; |
| import android.view.GestureDetector.SimpleOnGestureListener; |
| import android.view.KeyEvent; |
| import android.view.MotionEvent; |
| import android.view.MotionEvent.PointerCoords; |
| import android.view.MotionEvent.PointerProperties; |
| import android.view.ScaleGestureDetector; |
| import android.view.ScaleGestureDetector.OnScaleGestureListener; |
| import android.view.ViewConfiguration; |
| import android.view.accessibility.AccessibilityEvent; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| |
| /** |
| * This class handles magnification in response to touch events. |
| * |
| * The behavior is as follows: |
| * |
| * 1. Triple tap toggles permanent screen magnification which is magnifying |
| * the area around the location of the triple tap. One can think of the |
| * location of the triple tap as the center of the magnified viewport. |
| * For example, a triple tap when not magnified would magnify the screen |
| * and leave it in a magnified state. A triple tapping when magnified would |
| * clear magnification and leave the screen in a not magnified state. |
| * |
| * 2. Triple tap and hold would magnify the screen if not magnified and enable |
| * viewport dragging mode until the finger goes up. One can think of this |
| * mode as a way to move the magnified viewport since the area around the |
| * moving finger will be magnified to fit the screen. For example, if the |
| * screen was not magnified and the user triple taps and holds the screen |
| * would magnify and the viewport will follow the user's finger. When the |
| * finger goes up the screen will zoom out. If the same user interaction |
| * is performed when the screen is magnified, the viewport movement will |
| * be the same but when the finger goes up the screen will stay magnified. |
| * In other words, the initial magnified state is sticky. |
| * |
| * 3. Magnification can optionally be "triggered" by some external shortcut |
| * affordance. When this occurs via {@link #notifyShortcutTriggered()} a |
| * subsequent tap in a magnifiable region will engage permanent screen |
| * magnification as described in #1. Alternatively, a subsequent long-press |
| * or drag will engage magnification with viewport dragging as described in |
| * #2. Once magnified, all following behaviors apply whether magnification |
| * was engaged via a triple-tap or by a triggered shortcut. |
| * |
| * 4. Pinching with any number of additional fingers when viewport dragging |
| * is enabled, i.e. the user triple tapped and holds, would adjust the |
| * magnification scale which will become the current default magnification |
| * scale. The next time the user magnifies the same magnification scale |
| * would be used. |
| * |
| * 5. When in a permanent magnified state the user can use two or more fingers |
| * to pan the viewport. Note that in this mode the content is panned as |
| * opposed to the viewport dragging mode in which the viewport is moved. |
| * |
| * 6. When in a permanent magnified state the user can use two or more |
| * fingers to change the magnification scale which will become the current |
| * default magnification scale. The next time the user magnifies the same |
| * magnification scale would be used. |
| * |
| * 7. The magnification scale will be persisted in settings and in the cloud. |
| */ |
| @SuppressWarnings("WeakerAccess") |
| class MagnificationGestureHandler implements EventStreamTransformation { |
| private static final String LOG_TAG = "MagnificationEventHandler"; |
| |
| 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; |
| |
| /** @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; |
| |
| @VisibleForTesting final MagnificationController mMagnificationController; |
| |
| @VisibleForTesting final DetectingStateHandler mDetectingStateHandler; |
| @VisibleForTesting final PanningScalingStateHandler mPanningScalingStateHandler; |
| @VisibleForTesting final ViewportDraggingStateHandler mViewportDraggingStateHandler; |
| |
| private final ScreenStateReceiver mScreenStateReceiver; |
| |
| /** |
| * {@code true} if this detector should detect and respond to triple-tap |
| * gestures for engaging and disengaging magnification, |
| * {@code false} if it should ignore such gestures |
| */ |
| final boolean mDetectTripleTap; |
| |
| /** |
| * Whether {@link #mShortcutTriggered shortcut} is enabled |
| */ |
| final boolean mDetectShortcutTrigger; |
| |
| EventStreamTransformation mNext; |
| |
| @VisibleForTesting int mCurrentState; |
| @VisibleForTesting int mPreviousState; |
| |
| @VisibleForTesting boolean mShortcutTriggered; |
| |
| /** |
| * Time of last {@link MotionEvent#ACTION_DOWN} while in {@link #STATE_DELEGATING} |
| */ |
| long mDelegatingStateDownTime; |
| |
| private PointerCoords[] mTempPointerCoords; |
| private PointerProperties[] mTempPointerProperties; |
| |
| /** |
| * @param context Context for resolving various magnification-related resources |
| * @param magnificationController the {@link MagnificationController} |
| * |
| * @param detectTripleTap {@code true} if this detector should detect and respond to triple-tap |
| * gestures for engaging and disengaging magnification, |
| * {@code false} if it should ignore such gestures |
| * @param detectShortcutTrigger {@code true} if this detector should be "triggerable" by some |
| * external shortcut invoking {@link #notifyShortcutTriggered}, |
| * {@code false} if it should ignore such triggers. |
| */ |
| public MagnificationGestureHandler(Context context, |
| MagnificationController magnificationController, |
| boolean detectTripleTap, |
| boolean detectShortcutTrigger) { |
| mMagnificationController = magnificationController; |
| |
| 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; |
| } |
| |
| transitionTo(STATE_DETECTING); |
| } |
| |
| @Override |
| public void onMotionEvent(MotionEvent event, MotionEvent rawEvent, int policyFlags) { |
| if ((!mDetectTripleTap && !mDetectShortcutTrigger) |
| || !event.isFromSource(SOURCE_TOUCHSCREEN)) { |
| dispatchTransformedEvent(event, rawEvent, policyFlags); |
| return; |
| } |
| // 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); |
| } |
| break; |
| case STATE_DETECTING: { |
| mDetectingStateHandler.onMotionEvent(event, rawEvent, policyFlags); |
| } |
| break; |
| case STATE_VIEWPORT_DRAGGING: { |
| mViewportDraggingStateHandler.onMotionEvent(event, rawEvent, policyFlags); |
| } |
| break; |
| 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: " + currentState); |
| } |
| } |
| } |
| |
| @Override |
| public void onKeyEvent(KeyEvent event, int policyFlags) { |
| if (mNext != null) { |
| mNext.onKeyEvent(event, policyFlags); |
| } |
| } |
| |
| @Override |
| public void onAccessibilityEvent(AccessibilityEvent event) { |
| if (mNext != null) { |
| mNext.onAccessibilityEvent(event); |
| } |
| } |
| |
| @Override |
| public void setNext(EventStreamTransformation next) { |
| mNext = next; |
| } |
| |
| @Override |
| public void clearEvents(int inputSource) { |
| if (inputSource == SOURCE_TOUCHSCREEN) { |
| clearAndTransitionToStateDetecting(); |
| } |
| |
| if (mNext != null) { |
| mNext.clearEvents(inputSource); |
| } |
| } |
| |
| @Override |
| public void onDestroy() { |
| if (mScreenStateReceiver != null) { |
| mScreenStateReceiver.unregister(); |
| } |
| clearAndTransitionToStateDetecting(); |
| } |
| |
| void notifyShortcutTriggered() { |
| if (mDetectShortcutTrigger) { |
| boolean wasMagnifying = mMagnificationController.resetIfNeeded(/* animate */ true); |
| if (wasMagnifying) { |
| clearAndTransitionToStateDetecting(); |
| } else { |
| toggleShortcutTriggered(); |
| } |
| } |
| } |
| |
| private void toggleShortcutTriggered() { |
| setShortcutTriggered(!mShortcutTriggered); |
| } |
| |
| private void setShortcutTriggered(boolean state) { |
| if (mShortcutTriggered == state) { |
| return; |
| } |
| |
| mShortcutTriggered = state; |
| mMagnificationController.setForceShowMagnifiableBounds(state); |
| } |
| |
| void clearAndTransitionToStateDetecting() { |
| setShortcutTriggered(false); |
| mCurrentState = STATE_DETECTING; |
| mDetectingStateHandler.clear(); |
| mViewportDraggingStateHandler.clear(); |
| mPanningScalingStateHandler.clear(); |
| } |
| |
| private void handleMotionEventStateDelegating(MotionEvent event, |
| MotionEvent rawEvent, int policyFlags) { |
| 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. |
| // If no magnification is triggered we inject these events with adjusted |
| // time and down time to prevent subsequent transformations being confused |
| // by stale events. After the cached events, which always have a down, are |
| // injected we need to also update the down time of all subsequent non cached |
| // events. All delegated events cached and non-cached are delivered here. |
| event.setDownTime(mDelegatingStateDownTime); |
| dispatchTransformedEvent(event, rawEvent, policyFlags); |
| } |
| } |
| |
| private void dispatchTransformedEvent(MotionEvent event, MotionEvent rawEvent, |
| int policyFlags) { |
| 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. |
| if (mMagnificationController.isMagnifying() |
| && 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(); |
| final int pointerCount = event.getPointerCount(); |
| PointerCoords[] coords = getTempPointerCoordsWithMinSize(pointerCount); |
| PointerProperties[] properties = getTempPointerPropertiesWithMinSize( |
| pointerCount); |
| for (int i = 0; i < pointerCount; i++) { |
| event.getPointerCoords(i, coords[i]); |
| coords[i].x = (coords[i].x - scaledOffsetX) / scale; |
| coords[i].y = (coords[i].y - scaledOffsetY) / scale; |
| event.getPointerProperties(i, properties[i]); |
| } |
| event = MotionEvent.obtain(event.getDownTime(), |
| event.getEventTime(), event.getAction(), pointerCount, properties, |
| coords, 0, 0, 1.0f, 1.0f, event.getDeviceId(), 0, event.getSource(), |
| event.getFlags()); |
| } |
| mNext.onMotionEvent(event, rawEvent, policyFlags); |
| } |
| |
| private PointerCoords[] getTempPointerCoordsWithMinSize(int size) { |
| final int oldSize = (mTempPointerCoords != null) ? mTempPointerCoords.length : 0; |
| if (oldSize < size) { |
| PointerCoords[] oldTempPointerCoords = mTempPointerCoords; |
| mTempPointerCoords = new PointerCoords[size]; |
| if (oldTempPointerCoords != null) { |
| System.arraycopy(oldTempPointerCoords, 0, mTempPointerCoords, 0, oldSize); |
| } |
| } |
| for (int i = oldSize; i < size; i++) { |
| mTempPointerCoords[i] = new PointerCoords(); |
| } |
| return mTempPointerCoords; |
| } |
| |
| private PointerProperties[] getTempPointerPropertiesWithMinSize(int size) { |
| final int oldSize = (mTempPointerProperties != null) ? mTempPointerProperties.length |
| : 0; |
| if (oldSize < size) { |
| PointerProperties[] oldTempPointerProperties = mTempPointerProperties; |
| mTempPointerProperties = new PointerProperties[size]; |
| if (oldTempPointerProperties != null) { |
| System.arraycopy(oldTempPointerProperties, 0, mTempPointerProperties, 0, |
| oldSize); |
| } |
| } |
| for (int i = oldSize; i < size; i++) { |
| mTempPointerProperties[i] = new PointerProperties(); |
| } |
| return mTempPointerProperties; |
| } |
| |
| private void transitionTo(int state) { |
| if (DEBUG_STATE_TRANSITIONS) { |
| 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); |
| |
| void clear(); |
| } |
| |
| /** |
| * This class determines if the user is performing a scale or pan gesture. |
| * |
| * @see #STATE_PANNING_SCALING |
| */ |
| final class PanningScalingStateHandler extends SimpleOnGestureListener |
| implements OnScaleGestureListener, MotionEventHandler { |
| |
| private final ScaleGestureDetector mScaleGestureDetector; |
| private final GestureDetector mGestureDetector; |
| final float mScalingThreshold; |
| |
| float mInitialScaleFactor = -1; |
| boolean mScaling; |
| |
| public PanningScalingStateHandler(Context context) { |
| final TypedValue scaleValue = new TypedValue(); |
| context.getResources().getValue( |
| com.android.internal.R.dimen.config_screen_magnification_scaling_threshold, |
| scaleValue, false); |
| mScalingThreshold = scaleValue.getFloat(); |
| mScaleGestureDetector = new ScaleGestureDetector(context, this); |
| mScaleGestureDetector.setQuickScaleEnabled(false); |
| mGestureDetector = new GestureDetector(context, this); |
| } |
| |
| @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_PANNING_SCALING) { |
| return; |
| } |
| |
| 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_PANNING_SCALING) { |
| return true; |
| } |
| if (DEBUG_PANNING) { |
| Slog.i(LOG_TAG, "Panned content by scrollX: " + distanceX |
| + " scrollY: " + distanceY); |
| } |
| mMagnificationController.offsetMagnifiedRegion(distanceX, distanceY, |
| AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); |
| return true; |
| } |
| |
| @Override |
| public boolean onScale(ScaleGestureDetector detector) { |
| if (!mScaling) { |
| if (mInitialScaleFactor < 0) { |
| mInitialScaleFactor = detector.getScaleFactor(); |
| return false; |
| } |
| final float deltaScale = detector.getScaleFactor() - mInitialScaleFactor; |
| if (abs(deltaScale) > mScalingThreshold) { |
| mScaling = true; |
| return true; |
| } else { |
| return false; |
| } |
| } |
| |
| final float initialScale = mMagnificationController.getScale(); |
| final float targetScale = initialScale * detector.getScaleFactor(); |
| |
| // Don't allow a gesture to move the user further outside the |
| // desired bounds for gesture-controlled scaling. |
| final float scale; |
| if (targetScale > MAX_SCALE && targetScale > initialScale) { |
| // The target scale is too big and getting bigger. |
| scale = MAX_SCALE; |
| } else if (targetScale < MIN_SCALE && targetScale < initialScale) { |
| // The target scale is too small and getting smaller. |
| scale = MIN_SCALE; |
| } else { |
| // The target scale may be outside our bounds, but at least |
| // it's moving in the right direction. This avoids a "jump" if |
| // we're at odds with some other service's desired bounds. |
| scale = targetScale; |
| } |
| |
| final float pivotX = detector.getFocusX(); |
| final float pivotY = detector.getFocusY(); |
| mMagnificationController.setScale(scale, pivotX, pivotY, false, |
| AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); |
| return true; |
| } |
| |
| @Override |
| public boolean onScaleBegin(ScaleGestureDetector detector) { |
| return (mCurrentState == STATE_PANNING_SCALING); |
| } |
| |
| @Override |
| public void onScaleEnd(ScaleGestureDetector detector) { |
| clear(); |
| } |
| |
| @Override |
| public void clear() { |
| 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 |
| */ |
| 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 ACTION_POINTER_DOWN: { |
| clear(); |
| transitionTo(STATE_PANNING_SCALING); |
| } |
| break; |
| case ACTION_MOVE: { |
| if (event.getPointerCount() != 1) { |
| throw new IllegalStateException("Should have one pointer down."); |
| } |
| final float eventX = event.getX(); |
| final float eventY = event.getY(); |
| if (mMagnificationController.magnificationRegionContains(eventX, eventY)) { |
| mMagnificationController.setCenter(eventX, eventY, |
| /* animate */ mLastMoveOutsideMagnifiedRegion, |
| AccessibilityManagerService.MAGNIFICATION_GESTURE_HANDLER_ID); |
| mLastMoveOutsideMagnifiedRegion = false; |
| } else { |
| mLastMoveOutsideMagnifiedRegion = true; |
| } |
| } |
| break; |
| case ACTION_UP: { |
| if (!mZoomedInBeforeDrag) zoomOff(); |
| clear(); |
| transitionTo(STATE_DETECTING); |
| } |
| break; |
| |
| case ACTION_DOWN: |
| case ACTION_POINTER_UP: { |
| throw new IllegalArgumentException( |
| "Unexpected event type: " + MotionEvent.actionToString(action)); |
| } |
| } |
| } |
| |
| @Override |
| public void clear() { |
| mLastMoveOutsideMagnifiedRegion = false; |
| } |
| |
| @Override |
| public String toString() { |
| return "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 |
| */ |
| final class DetectingStateHandler implements MotionEventHandler, Handler.Callback { |
| |
| private static final int MESSAGE_ON_TRIPLE_TAP_AND_HOLD = 1; |
| private static final int MESSAGE_TRANSITION_TO_DELEGATING_STATE = 2; |
| |
| final int mLongTapMinDelay = 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; |
| |
| Handler mHandler = new Handler(this); |
| |
| public DetectingStateHandler(Context context) { |
| mMultiTapMaxDelay = ViewConfiguration.getDoubleTapTimeout() |
| + context.getResources().getInteger( |
| com.android.internal.R.integer.config_screen_magnification_multi_tap_adjustment); |
| mSwipeMinDistance = ViewConfiguration.get(context).getScaledTouchSlop(); |
| mMultiTapMaxDistance = ViewConfiguration.get(context).getScaledDoubleTapSlop(); |
| } |
| |
| @Override |
| public boolean handleMessage(Message message) { |
| final int type = message.what; |
| switch (type) { |
| case MESSAGE_ON_TRIPLE_TAP_AND_HOLD: { |
| 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); |
| switch (event.getActionMasked()) { |
| case MotionEvent.ACTION_DOWN: { |
| |
| mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE); |
| |
| if (!mMagnificationController.magnificationRegionContains( |
| event.getX(), event.getY())) { |
| |
| 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 { |
| |
| // Delegate pending events without delay |
| transitionToDelegatingState(/* andClear */ true); |
| } |
| } |
| break; |
| case ACTION_POINTER_DOWN: { |
| if (mMagnificationController.isMagnifying()) { |
| transitionTo(STATE_PANNING_SCALING); |
| clear(); |
| } else { |
| transitionToDelegatingState(/* andClear */ true); |
| } |
| } |
| break; |
| 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 ACTION_UP: { |
| |
| mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD); |
| |
| if (!mMagnificationController.magnificationRegionContains( |
| event.getX(), event.getY())) { |
| |
| 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); |
| |
| } |
| } |
| 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() { |
| setShortcutTriggered(false); |
| mHandler.removeMessages(MESSAGE_ON_TRIPLE_TAP_AND_HOLD); |
| mHandler.removeMessages(MESSAGE_TRANSITION_TO_DELEGATING_STATE); |
| clearDelayedMotionEvents(); |
| } |
| |
| |
| 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) { |
| mDelayedEventQueue = info; |
| } else { |
| MotionEventInfo tail = mDelayedEventQueue; |
| while (tail.mNext != null) { |
| tail = tail.mNext; |
| } |
| tail.mNext = info; |
| } |
| } |
| |
| private void sendDelayedMotionEvents() { |
| while (mDelayedEventQueue != null) { |
| MotionEventInfo info = mDelayedEventQueue; |
| mDelayedEventQueue = info.mNext; |
| |
| // Because MagnifiedInteractionStateHandler requires well-formed event stream |
| mPanningScalingStateHandler.onMotionEvent( |
| info.event, info.rawEvent, info.policyFlags); |
| |
| delegateEvent(info.event, info.rawEvent, info.policyFlags); |
| |
| info.recycle(); |
| } |
| } |
| |
| private void clearDelayedMotionEvents() { |
| while (mDelayedEventQueue != null) { |
| MotionEventInfo info = mDelayedEventQueue; |
| mDelayedEventQueue = info.mNext; |
| info.recycle(); |
| } |
| mPreLastDown = null; |
| mPreLastUp = null; |
| mLastDown = null; |
| mLastUp = null; |
| } |
| |
| void transitionToDelegatingState(boolean andClear) { |
| transitionTo(STATE_DELEGATING); |
| sendDelayedMotionEvents(); |
| if (andClear) clear(); |
| } |
| |
| private void onTripleTap(MotionEvent up) { |
| |
| if (DEBUG_DETECTING) { |
| Slog.i(LOG_TAG, "onTripleTap(); delayed: " |
| + MotionEventInfo.toString(mDelayedEventQueue)); |
| } |
| clear(); |
| |
| // 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 event; |
| public MotionEvent rawEvent; |
| public int policyFlags; |
| |
| public static MotionEventInfo obtain(MotionEvent event, MotionEvent rawEvent, |
| int policyFlags) { |
| synchronized (sLock) { |
| MotionEventInfo info = obtainInternal(); |
| info.initialize(event, rawEvent, policyFlags); |
| return info; |
| } |
| } |
| |
| @NonNull |
| private static MotionEventInfo obtainInternal() { |
| MotionEventInfo info; |
| if (sPoolSize > 0) { |
| sPoolSize--; |
| info = sPool; |
| sPool = info.mNext; |
| info.mNext = null; |
| info.mInPool = false; |
| } else { |
| info = new MotionEventInfo(); |
| } |
| return info; |
| } |
| |
| private void initialize(MotionEvent event, MotionEvent rawEvent, |
| int policyFlags) { |
| this.event = MotionEvent.obtain(event); |
| this.rawEvent = MotionEvent.obtain(rawEvent); |
| this.policyFlags = policyFlags; |
| } |
| |
| public void recycle() { |
| synchronized (sLock) { |
| if (mInPool) { |
| throw new IllegalStateException("Already recycled."); |
| } |
| clear(); |
| if (sPoolSize < MAX_POOL_SIZE) { |
| sPoolSize++; |
| mNext = sPool; |
| sPool = this; |
| mInPool = true; |
| } |
| } |
| } |
| |
| private void clear() { |
| event = recycleAndNullify(event); |
| rawEvent = recycleAndNullify(rawEvent); |
| policyFlags = 0; |
| } |
| |
| static int countOf(MotionEventInfo info, int eventType) { |
| if (info == null) return 0; |
| return (info.event.getAction() == eventType ? 1 : 0) |
| + countOf(info.mNext, eventType); |
| } |
| |
| public static String toString(MotionEventInfo info) { |
| return info == null |
| ? "" |
| : MotionEvent.actionToString(info.event.getAction()).replace("ACTION_", "") |
| + " " + MotionEventInfo.toString(info.mNext); |
| } |
| } |
| |
| /** |
| * BroadcastReceiver used to cancel the magnification shortcut when the screen turns off |
| */ |
| private static class ScreenStateReceiver extends BroadcastReceiver { |
| private final Context mContext; |
| private final MagnificationGestureHandler mGestureHandler; |
| |
| public ScreenStateReceiver(Context context, MagnificationGestureHandler gestureHandler) { |
| mContext = context; |
| mGestureHandler = gestureHandler; |
| } |
| |
| public void register() { |
| mContext.registerReceiver(this, new IntentFilter(Intent.ACTION_SCREEN_OFF)); |
| } |
| |
| public void unregister() { |
| mContext.unregisterReceiver(this); |
| } |
| |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| mGestureHandler.setShortcutTriggered(false); |
| } |
| } |
| } |