diff options
5 files changed, 453 insertions, 49 deletions
diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationGestureDetector.java b/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationGestureDetector.java new file mode 100644 index 000000000000..4c892e29f386 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationGestureDetector.java @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2020 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.systemui.accessibility; + +import android.annotation.DisplayContext; +import android.annotation.NonNull; +import android.content.Context; +import android.graphics.PointF; +import android.os.Handler; +import android.view.Display; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +/** + * Detects single tap and drag gestures using the supplied {@link MotionEvent}s. The {@link + * OnGestureListener} callback will notify users when a particular motion event has occurred. This + * class should only be used with {@link MotionEvent}s reported via touch (don't use for trackball + * events). + */ +class MagnificationGestureDetector { + + interface OnGestureListener { + /** + * Called when a tap is completed within {@link ViewConfiguration#getLongPressTimeout()} and + * the offset between {@link MotionEvent}s and the down event doesn't exceed {@link + * ViewConfiguration#getScaledTouchSlop()}. + * + * @return {@code true} if this gesture is handled. + */ + boolean onSingleTap(); + + /** + * Called when the user is performing dragging gesture. It is started after the offset + * between the down location and the move event location exceed + * {@link ViewConfiguration#getScaledTouchSlop()}. + * + * @param offsetX The X offset in screen coordinate. + * @param offsetY The Y offset in screen coordinate. + * @return {@code true} if this gesture is handled. + */ + boolean onDrag(float offsetX, float offsetY); + + /** + * Notified when a tap occurs with the down {@link MotionEvent} that triggered it. This will + * be triggered immediately for every down event. All other events should be preceded by + * this. + * + * @param x The X coordinate of the down event. + * @param y The Y coordinate of the down event. + * @return {@code true} if the down event is handled, otherwise the events won't be sent to + * the view. + */ + boolean onStart(float x, float y); + + /** + * Called when the detection is finished. In other words, it is called when up/cancel {@link + * MotionEvent} is received. It will be triggered after single-tap + * + * @param x The X coordinate on the screen of the up event or the cancel event. + * @param y The Y coordinate on the screen of the up event or the cancel event. + * @return {@code true} if the event is handled. + */ + boolean onFinish(float x, float y); + } + + private final PointF mPointerDown = new PointF(); + private final PointF mPointerLocation = new PointF(Float.NaN, Float.NaN); + private final Handler mHandler; + private final Runnable mCancelTapGestureRunnable; + private final OnGestureListener mOnGestureListener; + private int mTouchSlopSquare; + // Assume the gesture default is a single-tap. Set it to false if the gesture couldn't be a + // single-tap anymore. + private boolean mDetectSingleTap = true; + private boolean mDraggingDetected = false; + + /** + * @param context {@link Context} that is from {@link Context#createDisplayContext(Display)}. + * @param handler The handler to post the runnable. + * @param listener The listener invoked for all the callbacks. + */ + MagnificationGestureDetector(@DisplayContext Context context, @NonNull Handler handler, + @NonNull OnGestureListener listener) { + final int touchSlop = ViewConfiguration.get(context).getScaledTouchSlop(); + mTouchSlopSquare = touchSlop * touchSlop; + mHandler = handler; + mOnGestureListener = listener; + mCancelTapGestureRunnable = () -> mDetectSingleTap = false; + } + + /** + * Analyzes the given motion event and if applicable to trigger the appropriate callbacks on the + * {@link OnGestureListener} supplied. + * + * @param event The current motion event. + * @return {@code True} if the {@link OnGestureListener} consumes the event, else false. + */ + boolean onTouch(MotionEvent event) { + final float rawX = event.getRawX(); + final float rawY = event.getRawY(); + boolean handled = false; + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + mPointerDown.set(rawX, rawY); + mHandler.postAtTime(mCancelTapGestureRunnable, + event.getDownTime() + ViewConfiguration.getLongPressTimeout()); + handled |= mOnGestureListener.onStart(rawX, rawY); + break; + case MotionEvent.ACTION_POINTER_DOWN: + stopSingleTapDetection(); + break; + case MotionEvent.ACTION_MOVE: + stopSingleTapDetectionIfNeeded(rawX, rawY); + handled |= notifyDraggingGestureIfNeeded(rawX, rawY); + break; + case MotionEvent.ACTION_UP: + stopSingleTapDetectionIfNeeded(rawX, rawY); + if (mDetectSingleTap) { + handled |= mOnGestureListener.onSingleTap(); + } + // Fall through + case MotionEvent.ACTION_CANCEL: + handled |= mOnGestureListener.onFinish(rawX, rawY); + reset(); + break; + } + return handled; + } + + private void stopSingleTapDetectionIfNeeded(float x, float y) { + if (mDraggingDetected) { + return; + } + if (!isLocationValid(mPointerDown)) { + return; + } + + final int deltaX = (int) (mPointerDown.x - x); + final int deltaY = (int) (mPointerDown.y - y); + final int distanceSquare = (deltaX * deltaX) + (deltaY * deltaY); + if (distanceSquare > mTouchSlopSquare) { + mDraggingDetected = true; + stopSingleTapDetection(); + } + } + + private void stopSingleTapDetection() { + mHandler.removeCallbacks(mCancelTapGestureRunnable); + mDetectSingleTap = false; + } + + private boolean notifyDraggingGestureIfNeeded(float x, float y) { + if (!mDraggingDetected) { + return false; + } + if (!isLocationValid(mPointerLocation)) { + mPointerLocation.set(mPointerDown); + } + final float offsetX = x - mPointerLocation.x; + final float offsetY = y - mPointerLocation.y; + mPointerLocation.set(x, y); + return mOnGestureListener.onDrag(offsetX, offsetY); + } + + private void reset() { + resetPointF(mPointerDown); + resetPointF(mPointerLocation); + mHandler.removeCallbacks(mCancelTapGestureRunnable); + mDetectSingleTap = true; + mDraggingDetected = false; + } + + private static void resetPointF(PointF pointF) { + pointF.x = Float.NaN; + pointF.y = Float.NaN; + } + + private static boolean isLocationValid(PointF location) { + return !Float.isNaN(location.x) && !Float.isNaN(location.y); + } +} diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationModeSwitch.java b/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationModeSwitch.java index edc3216e0b81..c1cf8d31bd67 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationModeSwitch.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/MagnificationModeSwitch.java @@ -22,16 +22,13 @@ import android.annotation.NonNull; import android.content.Context; import android.content.pm.ActivityInfo; import android.graphics.PixelFormat; -import android.graphics.PointF; import android.graphics.Rect; import android.os.Bundle; import android.os.UserHandle; import android.provider.Settings; -import android.util.MathUtils; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; -import android.view.ViewConfiguration; import android.view.WindowManager; import android.view.WindowManager.LayoutParams; import android.view.accessibility.AccessibilityManager; @@ -46,11 +43,11 @@ import java.util.Collections; /** * Shows/hides a {@link android.widget.ImageView} on the screen and changes the values of - * {@link Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled. + * {@link Settings.Secure#ACCESSIBILITY_MAGNIFICATION_MODE} when the UI is toggled. * The button icon is movable by dragging. And the button UI would automatically be dismissed after * displaying for a period of time. */ -class MagnificationModeSwitch { +class MagnificationModeSwitch implements MagnificationGestureDetector.OnGestureListener { @VisibleForTesting static final long FADING_ANIMATION_DURATION_MS = 300; @@ -66,13 +63,11 @@ class MagnificationModeSwitch { private final AccessibilityManager mAccessibilityManager; private final WindowManager mWindowManager; private final ImageView mImageView; - private final PointF mLastDown = new PointF(); - private final PointF mLastDrag = new PointF(); - private final int mTapTimeout = ViewConfiguration.getTapTimeout(); - private final int mTouchSlop; private int mMagnificationMode = Settings.Secure.ACCESSIBILITY_MAGNIFICATION_MODE_FULLSCREEN; private final LayoutParams mParams; private boolean mIsVisible = false; + private final MagnificationGestureDetector mGestureDetector; + private boolean mSingleTapDetected = false; MagnificationModeSwitch(Context context) { this(context, createView(context)); @@ -86,7 +81,6 @@ class MagnificationModeSwitch { Context.WINDOW_SERVICE); mParams = createLayoutParams(context); mImageView = imageView; - mTouchSlop = ViewConfiguration.get(mContext).getScaledTouchSlop(); applyResourcesValues(); mImageView.setOnTouchListener(this::onTouch); mImageView.setAccessibilityDelegate(new View.AccessibilityDelegate() { @@ -127,6 +121,8 @@ class MagnificationModeSwitch { .start(); mIsFadeOutAnimating = true; }; + mGestureDetector = new MagnificationGestureDetector(context, + context.getMainThreadHandler(), this); } private CharSequence formatStateDescription() { @@ -144,39 +140,38 @@ class MagnificationModeSwitch { } private boolean onTouch(View v, MotionEvent event) { - if (!mIsVisible || mImageView == null) { + if (!mIsVisible) { return false; } - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - stopFadeOutAnimation(); - mLastDown.set(event.getRawX(), event.getRawY()); - mLastDrag.set(event.getRawX(), event.getRawY()); - return true; - case MotionEvent.ACTION_MOVE: - // Move the button position. - moveButton(event.getRawX() - mLastDrag.x, - event.getRawY() - mLastDrag.y); - mLastDrag.set(event.getRawX(), event.getRawY()); - return true; - case MotionEvent.ACTION_UP: - // Single tap to toggle magnification mode and the button position will be reset - // after the action is performed. - final float distance = MathUtils.dist(mLastDown.x, mLastDown.y, - event.getRawX(), event.getRawY()); - if ((event.getEventTime() - event.getDownTime()) <= mTapTimeout - && distance <= mTouchSlop) { - handleSingleTap(); - } else { - showButton(mMagnificationMode); - } - return true; - case MotionEvent.ACTION_CANCEL: - showButton(mMagnificationMode); - return true; - default: - return false; + return mGestureDetector.onTouch(event); + } + + @Override + public boolean onSingleTap() { + mSingleTapDetected = true; + handleSingleTap(); + return true; + } + + @Override + public boolean onDrag(float offsetX, float offsetY) { + moveButton(offsetX, offsetY); + return true; + } + + @Override + public boolean onStart(float x, float y) { + stopFadeOutAnimation(); + return true; + } + + @Override + public boolean onFinish(float xOffset, float yOffset) { + if (!mSingleTapDetected) { + showButton(mMagnificationMode); } + mSingleTapDetected = false; + return true; } private void moveButton(float offsetX, float offsetY) { diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationGestureDetectorTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationGestureDetectorTest.java new file mode 100644 index 000000000000..6f4846a601d9 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationGestureDetectorTest.java @@ -0,0 +1,173 @@ +/* + * Copyright (C) 2020 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.systemui.accessibility; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyFloat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import android.os.Handler; +import android.os.SystemClock; +import android.testing.AndroidTestingRunner; +import android.view.MotionEvent; +import android.view.ViewConfiguration; + +import androidx.test.filters.SmallTest; + +import com.android.systemui.SysuiTestCase; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; + + +@SmallTest +@RunWith(AndroidTestingRunner.class) +public class MagnificationGestureDetectorTest extends SysuiTestCase { + + private static final float ACTION_DOWN_X = 100; + private static final float ACTION_DOWN_Y = 200; + private int mTouchSlop = ViewConfiguration.get(getContext()).getScaledTouchSlop(); + private MagnificationGestureDetector mGestureDetector; + private MotionEventHelper mMotionEventHelper = new MotionEventHelper(); + @Mock + private MagnificationGestureDetector.OnGestureListener mListener; + @Mock + private Handler mHandler; + private Runnable mCancelSingleTapRunnable; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + doAnswer((invocation) -> { + mCancelSingleTapRunnable = invocation.getArgument(0); + return null; + }).when(mHandler).postAtTime(any(Runnable.class), anyLong()); + mGestureDetector = new MagnificationGestureDetector(mContext, mHandler, mListener); + } + + @After + public void tearDown() { + mMotionEventHelper.recycleEvents(); + } + + @Test + public void onActionDown_invokeDownCallback() { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + + mListener.onStart(ACTION_DOWN_X, ACTION_DOWN_Y); + } + + @Test + public void performSingleTap_invokeCallbacksInOrder() { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + mGestureDetector.onTouch(upEvent); + + InOrder inOrder = Mockito.inOrder(mListener); + inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y); + inOrder.verify(mListener).onSingleTap(); + inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y); + verify(mListener, never()).onDrag(anyFloat(), anyFloat()); + } + + @Test + public void performSingleTapWithActionCancel_notInvokeOnSingleTapCallback() { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + final MotionEvent cancelEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_CANCEL, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + mGestureDetector.onTouch(cancelEvent); + + verify(mListener, never()).onSingleTap(); + } + + @Test + public void performSingleTapWithTwoPointers_notInvokeSingleTapCallback() { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_POINTER_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + mGestureDetector.onTouch(upEvent); + + verify(mListener, never()).onSingleTap(); + } + + @Test + public void performLongPress_invokeCallbacksInOrder() { + final long downTime = SystemClock.uptimeMillis(); + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + // Execute the pending message for stopping single-tap detection. + mCancelSingleTapRunnable.run(); + mGestureDetector.onTouch(upEvent); + + InOrder inOrder = Mockito.inOrder(mListener); + inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y); + inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y); + verify(mListener, never()).onSingleTap(); + } + + @Test + public void performDrag_invokeCallbacksInOrder() { + final long downTime = SystemClock.uptimeMillis(); + final float dragOffset = mTouchSlop + 10; + final MotionEvent downEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_DOWN, ACTION_DOWN_X, ACTION_DOWN_Y); + final MotionEvent moveEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_MOVE, ACTION_DOWN_X + dragOffset, ACTION_DOWN_Y); + final MotionEvent upEvent = mMotionEventHelper.obtainMotionEvent(downTime, downTime, + MotionEvent.ACTION_UP, ACTION_DOWN_X, ACTION_DOWN_Y); + + mGestureDetector.onTouch(downEvent); + mGestureDetector.onTouch(moveEvent); + mGestureDetector.onTouch(upEvent); + + InOrder inOrder = Mockito.inOrder(mListener); + inOrder.verify(mListener).onStart(ACTION_DOWN_X, ACTION_DOWN_Y); + inOrder.verify(mListener).onDrag(dragOffset, 0); + inOrder.verify(mListener).onFinish(ACTION_DOWN_X, ACTION_DOWN_Y); + verify(mListener, never()).onSingleTap(); + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationModeSwitchTest.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationModeSwitchTest.java index 96f3c156c978..3d504fbefefe 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationModeSwitchTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MagnificationModeSwitchTest.java @@ -73,7 +73,6 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; -import java.util.ArrayList; import java.util.List; @SmallTest @@ -91,8 +90,8 @@ public class MagnificationModeSwitchTest extends SysuiTestCase { private ViewPropertyAnimator mViewPropertyAnimator; private MagnificationModeSwitch mMagnificationModeSwitch; private View.OnTouchListener mTouchListener; - private List<MotionEvent> mMotionEvents = new ArrayList<>(); private Runnable mFadeOutAnimation; + private MotionEventHelper mMotionEventHelper = new MotionEventHelper(); @Before public void setUp() throws Exception { @@ -117,11 +116,8 @@ public class MagnificationModeSwitchTest extends SysuiTestCase { @After public void tearDown() { - for (MotionEvent event:mMotionEvents) { - event.recycle(); - } - mMotionEvents.clear(); mFadeOutAnimation = null; + mMotionEventHelper.recycleEvents(); } @Test @@ -436,9 +432,7 @@ public class MagnificationModeSwitchTest extends SysuiTestCase { private MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x, float y) { - MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0); - mMotionEvents.add(event); - return event; + return mMotionEventHelper.obtainMotionEvent(downTime, eventTime, action, x, y); } private void executeFadeOutAnimation() { diff --git a/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java new file mode 100644 index 000000000000..92dad9bdb120 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/accessibility/MotionEventHelper.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020 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.systemui.accessibility; + +import android.view.MotionEvent; + +import com.android.internal.annotations.GuardedBy; + +import java.util.ArrayList; +import java.util.List; + +class MotionEventHelper { + @GuardedBy("this") + private final List<MotionEvent> mMotionEvents = new ArrayList<>(); + + void recycleEvents() { + for (MotionEvent event:mMotionEvents) { + event.recycle(); + } + synchronized (this) { + mMotionEvents.clear(); + } + } + + MotionEvent obtainMotionEvent(long downTime, long eventTime, int action, float x, + float y) { + MotionEvent event = MotionEvent.obtain(downTime, eventTime, action, x, y, 0); + synchronized (this) { + mMotionEvents.add(event); + } + return event; + } +} |