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

Commit c0cc90d0 authored by Jason Chang's avatar Jason Chang
Browse files

Support NavigationBar 3-button mode gesture for One-Handed mode

Adding new gesture handler for supporting NavigationBar
3-button mode gesture.

Bug: 157949041

Test: manual
Test: atest SystemUITests
Change-Id: Iaaa8592dd508504b03a625d3dce503ab7628b67e
parent d422a8b0
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -1371,4 +1371,8 @@
    <dimen name="config_rounded_mask_size">@*android:dimen/rounded_corner_radius</dimen>
    <dimen name="config_rounded_mask_size_top">@*android:dimen/rounded_corner_radius_top</dimen>
    <dimen name="config_rounded_mask_size_bottom">@*android:dimen/rounded_corner_radius_bottom</dimen>

    <!-- One-Handed Mode -->
    <!-- Threshold for dragging distance to enable one-handed mode -->
    <dimen name="gestures_onehanded_drag_threshold">20dp</dimen>
</resources>
+285 −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.onehanded;

import static android.view.Display.DEFAULT_DISPLAY;
import static android.view.WindowManagerPolicyConstants.NAV_BAR_MODE_3BUTTON;

import android.annotation.Nullable;
import android.content.Context;
import android.graphics.Point;
import android.graphics.PointF;
import android.graphics.Rect;
import android.hardware.input.InputManager;
import android.os.Looper;
import android.util.Log;
import android.view.Display;
import android.view.InputChannel;
import android.view.InputEvent;
import android.view.InputEventReceiver;
import android.view.InputMonitor;
import android.view.MotionEvent;
import android.view.Surface;
import android.view.ViewConfiguration;
import android.window.WindowContainerTransaction;

import androidx.annotation.VisibleForTesting;

import com.android.systemui.R;
import com.android.systemui.statusbar.phone.NavigationModeController;
import com.android.systemui.wm.DisplayChangeController;
import com.android.systemui.wm.DisplayController;

import javax.inject.Inject;
import javax.inject.Singleton;

/**
 * The class manage swipe up and down gesture for 3-Button mode navigation,
 * others(e.g, 2-button, full gesture mode) are handled by Launcher quick steps.
 */
@Singleton
public class OneHandedGestureHandler implements OneHandedTransitionCallback,
            NavigationModeController.ModeChangedListener,
            DisplayChangeController.OnDisplayChangingListener {
    private static final String TAG = "OneHandedGestureHandler";
    private static final boolean DEBUG_GESTURE = false;

    private static final int ANGLE_MAX = 150;
    private static final int ANGLE_MIN = 30;
    private final float mDragDistThreshold;
    private final float mSquaredSlop;
    private final PointF mDownPos = new PointF();
    private final PointF mLastPos = new PointF();
    private final PointF mStartDragPos = new PointF();
    private boolean mPassedSlop;

    private boolean mAllowGesture;
    private boolean mIsEnabled;
    private int mNavGestureHeight;
    private boolean mIsThreeButtonModeEnable;
    private int mRotation = Surface.ROTATION_0;

    @VisibleForTesting
    InputMonitor mInputMonitor;
    @VisibleForTesting
    InputEventReceiver mInputEventReceiver;
    private DisplayController mDisplayController;
    @VisibleForTesting
    @Nullable
    OneHandedGestureEventCallback mGestureEventCallback;
    private Rect mGestureRegion = new Rect();

    /**
     * Constructor of OneHandedGestureHandler, we only handle the gesture of
     * {@link Display#DEFAULT_DISPLAY}
     *
     * @param context           {@link Context}
     * @param displayController {@link DisplayController}
     * @param navigationModeController {@link NavigationModeController}
     */
    @Inject
    public OneHandedGestureHandler(Context context, DisplayController displayController,
            NavigationModeController navigationModeController) {
        mDisplayController = displayController;
        displayController.addDisplayChangingController(this);
        final int NavBarMode = navigationModeController.addListener(this);
        mIsThreeButtonModeEnable = (NavBarMode == NAV_BAR_MODE_3BUTTON);
        mNavGestureHeight = context.getResources().getDimensionPixelSize(
                com.android.internal.R.dimen.navigation_bar_gesture_height);
        mDragDistThreshold = context.getResources().getDimensionPixelSize(
                R.dimen.gestures_onehanded_drag_threshold);
        final float slop = ViewConfiguration.get(context).getScaledTouchSlop();
        mSquaredSlop =  slop * slop;
        updateIsEnabled();
    }

    /**
     * Notified by {@link OneHandedManager}, when user update settings of Enabled or Disabled
     *
     * @param isEnabled is one handed settings enabled or not
     */
    public void onOneHandedEnabled(boolean isEnabled) {
        if (DEBUG_GESTURE) {
            Log.d(TAG, "onOneHandedEnabled, isEnabled = " + isEnabled);
        }
        mIsEnabled = isEnabled;
        updateIsEnabled();
    }

    /**
     * Register {@link OneHandedGestureEventCallback} to receive onStart(), onStop() callback
     */
    public void setGestureEventListener(OneHandedGestureEventCallback callback) {
        mGestureEventCallback = callback;
    }

    private void onMotionEvent(MotionEvent ev) {
        int action = ev.getActionMasked();
        if (action == MotionEvent.ACTION_DOWN) {
            mAllowGesture = isWithinTouchRegion(ev.getX(), ev.getY())
                    && mRotation == Surface.ROTATION_0;
            if (mAllowGesture) {
                mDownPos.set(ev.getX(), ev.getY());
                mLastPos.set(mDownPos);
            }
            if (DEBUG_GESTURE) {
                Log.d(TAG, "ACTION_DOWN, mDownPos=" + mDownPos + ", mAllowGesture="
                        + mAllowGesture);
            }
        } else if (mAllowGesture) {
            switch (action) {
                case MotionEvent.ACTION_MOVE:
                    mLastPos.set(ev.getX(), ev.getY());
                    if (!mPassedSlop) {
                        if (squaredHypot(mLastPos.x - mDownPos.x, mLastPos.y - mDownPos.y)
                                > mSquaredSlop) {
                            mStartDragPos.set(mLastPos.x, mLastPos.y);
                            if (isValidStartAngle(
                                    mDownPos.x - mLastPos.x, mDownPos.y - mLastPos.y)
                                    || isValidExitAngle(
                                    mDownPos.x - mLastPos.x, mDownPos.y - mLastPos.y)) {
                                mPassedSlop = true;
                                mInputMonitor.pilferPointers();
                            }
                        }
                    } else {
                        float distance = (float) Math.hypot(mLastPos.x - mDownPos.x,
                                mLastPos.y - mDownPos.y);
                        if (distance > mDragDistThreshold && mPassedSlop) {
                            mGestureEventCallback.onStop();
                        }
                    }
                    break;
                case MotionEvent.ACTION_UP:
                    if (mLastPos.y >= mDownPos.y && mPassedSlop) {
                        mGestureEventCallback.onStart();
                    }
                    mPassedSlop = false;
                    mAllowGesture = false;
                    break;
                case MotionEvent.ACTION_CANCEL:
                    mPassedSlop = false;
                    mAllowGesture = false;
                    break;
                default:
                    break;
            }
        }
    }

    private void disposeInputChannel() {
        if (mInputEventReceiver != null) {
            mInputEventReceiver.dispose();
            mInputEventReceiver = null;
        }

        if (mInputMonitor != null) {
            mInputMonitor.dispose();
            mInputMonitor = null;
        }
    }

    private boolean isWithinTouchRegion(float x, float y) {
        if (DEBUG_GESTURE) {
            Log.d(TAG, "isWithinTouchRegion(), mGestureRegion=" + mGestureRegion + ", downX=" + x
                    + ", downY=" + y);
        }
        return mGestureRegion.contains(Math.round(x), Math.round(y));
    }

    private void updateIsEnabled() {
        disposeInputChannel();

        if (mIsEnabled && mIsThreeButtonModeEnable) {
            final Point displaySize = new Point();
            if (mDisplayController != null) {
                final Display display = mDisplayController.getDisplay(DEFAULT_DISPLAY);
                if (display != null) {
                    display.getRealSize(displaySize);
                }
            }
            // Register input event receiver to monitor the touch region of NavBar gesture height
            mGestureRegion.set(0, displaySize.y - mNavGestureHeight, displaySize.x,
                    displaySize.y);
            mInputMonitor = InputManager.getInstance().monitorGestureInput(
                    "onehanded-gesture-offset", DEFAULT_DISPLAY);
            mInputEventReceiver = new SysUiInputEventReceiver(
                    mInputMonitor.getInputChannel(), Looper.getMainLooper());
        }
    }

    private void onInputEvent(InputEvent ev) {
        if (ev instanceof MotionEvent) {
            onMotionEvent((MotionEvent) ev);
        }
    }

    @Override
    public void onNavigationModeChanged(int mode) {
        if (DEBUG_GESTURE) {
            Log.d(TAG, "onNavigationModeChanged, mode =" + mode);
        }
        mIsThreeButtonModeEnable = (mode == NAV_BAR_MODE_3BUTTON);
        updateIsEnabled();
    }

    @Override
    public void onRotateDisplay(int displayId, int fromRotation, int toRotation,
            WindowContainerTransaction t) {
        mRotation = toRotation;
    }

    private class SysUiInputEventReceiver extends InputEventReceiver {
        SysUiInputEventReceiver(InputChannel channel, Looper looper) {
            super(channel, looper);
        }

        public void onInputEvent(InputEvent event) {
            OneHandedGestureHandler.this.onInputEvent(event);
            finishInputEvent(event, true);
        }
    }

    private boolean isValidStartAngle(float deltaX, float deltaY) {
        final float angle = (float) Math.toDegrees(Math.atan2(deltaY, deltaX));
        return angle > -(ANGLE_MAX) && angle < -(ANGLE_MIN);
    }

    private boolean isValidExitAngle(float deltaX, float deltaY) {
        final float angle = (float) Math.toDegrees(Math.atan2(deltaY, deltaX));
        return angle > ANGLE_MIN && angle < ANGLE_MAX;
    }

    private float squaredHypot(float x, float y) {
        return x * x + y * y;
    }

    /**
     * The touch(gesture) events to notify {@link OneHandedManager} start or stop one handed
     */
    public interface OneHandedGestureEventCallback {
        /**
         * Handle the start event event, and return whether the event was consumed.
         */
        boolean onStart();

        /**
         * Handle the exit event event, and return whether the event was consumed.
         */
        boolean onStop();
    }
}
+29 −0
Original line number Diff line number Diff line
@@ -52,6 +52,8 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
    private float mOffSetFraction;
    private DisplayController mDisplayController;
    private OneHandedDisplayAreaOrganizer mDisplayAreaOrganizer;
    private OneHandedGestureHandler mGestureHandler;
    private OneHandedGestureHandler.OneHandedGestureEventCallback mGestureEventCallback;
    private OneHandedTimeoutHandler mTimeoutHandler;
    private OneHandedTouchHandler mTouchHandler;
    private OneHandedTouchHandler.OneHandedTouchEventCallback mTouchEventCallback;
@@ -89,6 +91,7 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
            DisplayController displayController,
            OneHandedDisplayAreaOrganizer displayAreaOrganizer,
            OneHandedTouchHandler touchHandler,
            OneHandedGestureHandler gestureHandler,
            SysUiState sysUiState) {

        mDisplayAreaOrganizer = displayAreaOrganizer;
@@ -100,6 +103,7 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
                context.getContentResolver());
        mTimeoutHandler = OneHandedTimeoutHandler.get();
        mTouchHandler = touchHandler;
        mGestureHandler = gestureHandler;
        updateOneHandedEnabled();
        setupGestures();
    }
@@ -170,6 +174,29 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
        };
        mTouchHandler.registerTouchEventListener(mTouchEventCallback);

        mGestureEventCallback = new OneHandedGestureHandler.OneHandedGestureEventCallback() {
            @Override
            public boolean onStart() {
                boolean result = false;
                if (!mDisplayAreaOrganizer.isInOneHanded()) {
                    startOneHanded();
                    result = true;
                }
                return result;
            }

            @Override
            public boolean onStop() {
                boolean result = false;
                if (mDisplayAreaOrganizer.isInOneHanded()) {
                    stopOneHanded();
                    result = true;
                }
                return result;
            }
        };
        mGestureHandler.setGestureEventListener(mGestureEventCallback);

        mTransitionCallback = new OneHandedTransitionCallback() {
            @Override
            public void onStartFinished(Rect bounds) {
@@ -185,6 +212,7 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
        };
        mDisplayAreaOrganizer.registerTransitionCallback(mTransitionCallback);
        mDisplayAreaOrganizer.registerTransitionCallback(mTouchHandler);
        mDisplayAreaOrganizer.registerTransitionCallback(mGestureHandler);
    }

    /**
@@ -215,6 +243,7 @@ public class OneHandedManagerImpl implements OneHandedManager, Dumpable {
            ActivityManagerWrapper.getInstance().registerTaskStackListener(mTaskStackListener);
        }
        mTouchHandler.onOneHandedEnabled(mIsOneHandedEnabled);
        mGestureHandler.onOneHandedEnabled(mIsOneHandedEnabled);
    }

    @Override
+118 −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.onehanded;

import static android.view.WindowManagerPolicyConstants.NAV_BAR_MODE_2BUTTON;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.app.Instrumentation;
import android.testing.AndroidTestingRunner;
import android.testing.TestableLooper;

import androidx.test.filters.SmallTest;
import androidx.test.platform.app.InstrumentationRegistry;

import com.android.systemui.model.SysUiState;
import com.android.systemui.statusbar.phone.NavigationModeController;
import com.android.systemui.wm.DisplayController;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.MockitoAnnotations;

@SmallTest
@RunWith(AndroidTestingRunner.class)
@TestableLooper.RunWithLooper
public class OneHandedGestureHandlerTest extends OneHandedTestCase {
    Instrumentation mInstrumentation;
    OneHandedTouchHandler mTouchHandler;
    OneHandedGestureHandler mGestureHandler;
    OneHandedManagerImpl mOneHandedManagerImpl;
    @Mock
    DisplayController mMockDisplayController;
    @Mock
    OneHandedDisplayAreaOrganizer mMockDisplayAreaOrganizer;
    @Mock
    SysUiState mMockSysUiState;
    @Mock
    NavigationModeController mMockNavigationModeController;
    @Before
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        mInstrumentation = InstrumentationRegistry.getInstrumentation();
        mTouchHandler = Mockito.spy(new OneHandedTouchHandler());
        mGestureHandler = Mockito.spy(new OneHandedGestureHandler(
                mContext, mMockDisplayController, mMockNavigationModeController));
        mOneHandedManagerImpl = new OneHandedManagerImpl(mInstrumentation.getContext(),
                mMockDisplayController,
                mMockDisplayAreaOrganizer,
                mTouchHandler,
                mGestureHandler,
                mMockSysUiState);
    }

    @Test
    public void testOneHandedManager_registerForDisplayAreaOrganizer() {
        verify(mMockDisplayAreaOrganizer, times(1)).registerTransitionCallback(mGestureHandler);
    }

    @Test
    public void testOneHandedManager_setGestureEventListener() {
        verify(mGestureHandler).setGestureEventListener(any());

        assertThat(mGestureHandler.mGestureEventCallback).isNotNull();
    }

    @Test
    public void testReceiveNewConfig_whenSetOneHandedEnabled() {
        // 1st called at init
        verify(mGestureHandler).onOneHandedEnabled(true);
        mOneHandedManagerImpl.setOneHandedEnabled(true);
        // 2nd called by setOneHandedEnabled()
        verify(mGestureHandler, times(2)).onOneHandedEnabled(true);
    }

    @Test
    public void testOneHandedDisabled_shouldDisposeInputChannel() {
        mOneHandedManagerImpl.setOneHandedEnabled(false);

        assertThat(mGestureHandler.mInputMonitor).isNull();
        assertThat(mGestureHandler.mInputEventReceiver).isNull();
    }

    @Test
    public void testChangeNavBarTo2Button_shouldDisposeInputChannel() {
        // 1st called at init
        verify(mGestureHandler).onOneHandedEnabled(true);
        mOneHandedManagerImpl.setOneHandedEnabled(true);
        // 2nd called by setOneHandedEnabled()
        verify(mGestureHandler, times(2)).onOneHandedEnabled(true);

        mGestureHandler.onNavigationModeChanged(NAV_BAR_MODE_2BUTTON);

        assertThat(mGestureHandler.mInputMonitor).isNull();
        assertThat(mGestureHandler.mInputEventReceiver).isNull();
    }
}
+3 −0
Original line number Diff line number Diff line
@@ -57,6 +57,8 @@ public class OneHandedManagerImplTest extends OneHandedTestCase {
    @Mock
    OneHandedTouchHandler mMockTouchHandler;
    @Mock
    OneHandedGestureHandler mMockGestureHandler;
    @Mock
    SysUiState mMockSysUiState;

    @Before
@@ -66,6 +68,7 @@ public class OneHandedManagerImplTest extends OneHandedTestCase {
                mMockDisplayController,
                mMockDisplayAreaOrganizer,
                mMockTouchHandler,
                mMockGestureHandler,
                mMockSysUiState);
        mTimeoutHandler = Mockito.spy(OneHandedTimeoutHandler.get());

Loading