Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit df5396ea authored by ryanlwlin's avatar ryanlwlin
Browse files

modularize the gesture detection mechanism for magnification

Currently more and more views have similar gesture.
Modularize them to reduce duplicate logic.

Bug: 173159759
Test: atest MagnificationModeSwitchTest
      atest MagnificationGestureDetectorTest
      manually test to see if function works well
Change-Id: I2d33322f331ec26e1d91f0c174e73f33a18acb52
parent c6eec7e5
Loading
Loading
Loading
Loading
+195 −0
Original line number Diff line number Diff line
/*
 * 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);
    }
}
+35 −40
Original line number Diff line number Diff line
@@ -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 mGestureDetector.onTouch(event);
    }

    @Override
    public boolean onSingleTap() {
        mSingleTapDetected = true;
        handleSingleTap();
        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());
    }

    @Override
    public boolean onDrag(float offsetX, float offsetY) {
        moveButton(offsetX, offsetY);
        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);
    }

    @Override
    public boolean onStart(float x, float y) {
        stopFadeOutAnimation();
        return true;
            case MotionEvent.ACTION_CANCEL:
    }

    @Override
    public boolean onFinish(float xOffset, float yOffset) {
        if (!mSingleTapDetected) {
            showButton(mMagnificationMode);
                return true;
            default:
                return false;
        }
        mSingleTapDetected = false;
        return true;
    }

    private void moveButton(float offsetX, float offsetY) {
+173 −0
Original line number Diff line number Diff line
/*
 * 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();
    }
}
+3 −9
Original line number Diff line number Diff line
@@ -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() {
+47 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}