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

Commit af17ef56 authored by Longbo Wei's avatar Longbo Wei
Browse files

a11y: Add countdown when hovering on exit

Right now, the scroll panel hides as soon as the user hovers over the
exit button, which is not ideal because it can close by accident.

This CL fixes it by adding a delay before hiding the panel when the exit
button is hovered. The delay time is the same as the set countdown.

Video: http://shortn/_JxFXNsIXKe

Bug: b/401509893
Test: AutoclickScrollPaneTest
Flag: com.android.server.accessibility.enable_autoclick_indicator
Change-Id: I537131d57e97ef83305fdb492b93423dd36596ce
parent ecfab510
Loading
Loading
Loading
Loading
+47 −7
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ import static android.view.accessibility.AccessibilityManager.AUTOCLICK_REVERT_T

import static com.android.server.accessibility.autoclick.AutoclickIndicatorView.SHOW_INDICATOR_DELAY_TIME;
import static com.android.server.accessibility.autoclick.AutoclickTypePanel.AUTOCLICK_TYPE_DOUBLE_CLICK;
import static com.android.server.accessibility.autoclick.AutoclickScrollPanel.DIRECTION_NONE;
import static com.android.server.accessibility.autoclick.AutoclickTypePanel.AUTOCLICK_TYPE_LEFT_CLICK;
import static com.android.server.accessibility.autoclick.AutoclickTypePanel.AUTOCLICK_TYPE_RIGHT_CLICK;
import static com.android.server.accessibility.autoclick.AutoclickTypePanel.AUTOCLICK_TYPE_SCROLL;
@@ -97,6 +98,9 @@ public class AutoclickController extends BaseEventStreamTransformation {
    // Default click type is left-click.
    private @AutoclickType int mActiveClickType = AUTOCLICK_TYPE_LEFT_CLICK;

    // Default scroll direction is DIRECTION_NONE.
    private @AutoclickScrollPanel.ScrollDirection int mHoveredDirection = DIRECTION_NONE;

    @VisibleForTesting
    final ClickPanelControllerInterface clickPanelController =
            new ClickPanelControllerInterface() {
@@ -131,14 +135,26 @@ public class AutoclickController extends BaseEventStreamTransformation {
    final AutoclickScrollPanel.ScrollPanelControllerInterface mScrollPanelController =
            new AutoclickScrollPanel.ScrollPanelControllerInterface() {
                @Override
                public void handleScroll(@AutoclickScrollPanel.ScrollDirection int direction) {
                    // TODO(b/388845721): Perform actual scroll.
                public void onHoverButtonChange(
                        @AutoclickScrollPanel.ScrollDirection int direction,
                        boolean hovered) {
                    // Update the hover direction.
                    if (hovered) {
                        mHoveredDirection = direction;
                    } else if (mHoveredDirection == direction) {
                        // Safety check: Only clear hover tracking if this is the same button
                        // we're currently tracking.
                        mHoveredDirection = AutoclickScrollPanel.DIRECTION_NONE;
                    }

                    // For exit button, we only trigger hover state changes, the autoclick system
                    // will handle the countdown.
                    if (direction == AutoclickScrollPanel.DIRECTION_EXIT) {
                        return;
                    }

                @Override
                public void exitScrollMode() {
                    if (mAutoclickScrollPanel != null) {
                        mAutoclickScrollPanel.hide();
                    // For direction buttons, perform scroll action immediately.
                    if (hovered && direction != AutoclickScrollPanel.DIRECTION_NONE) {
                        handleScroll(direction);
                    }
                }
            };
@@ -285,6 +301,22 @@ public class AutoclickController extends BaseEventStreamTransformation {
        }
    }

    /**
     * Handles scroll operations in the specified direction.
     */
    public void handleScroll(@AutoclickScrollPanel.ScrollDirection int direction) {
        // TODO(b/388845721): Perform actual scroll.
    }

    /**
     * Exits scroll mode and hides the scroll panel UI.
     */
    public void exitScrollMode() {
        if (mAutoclickScrollPanel != null) {
            mAutoclickScrollPanel.hide();
        }
    }

    @VisibleForTesting
    void onChangeForTesting(boolean selfChange, Uri uri) {
        mAutoclickSettingsObserver.onChange(selfChange, uri);
@@ -776,6 +808,14 @@ public class AutoclickController extends BaseEventStreamTransformation {
                return;
            }

            if (mAutoclickScrollPanel != null && mAutoclickScrollPanel.isVisible()) {
                // If exit button is hovered, exit scroll mode after countdown and return early.
                if (mHoveredDirection == AutoclickScrollPanel.DIRECTION_EXIT) {
                    exitScrollMode();
                }
                return;
            }

            // Handle scroll type specially, show scroll panel instead of sending click events.
            if (mActiveClickType == AutoclickTypePanel.AUTOCLICK_TYPE_SCROLL) {
                if (mAutoclickScrollPanel != null) {
+44 −27
Original line number Diff line number Diff line
@@ -23,6 +23,7 @@ import android.content.Context;
import android.graphics.PixelFormat;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.MotionEvent;
import android.view.View;
import android.view.WindowInsets;
import android.view.WindowManager;
@@ -41,12 +42,16 @@ public class AutoclickScrollPanel {
    public static final int DIRECTION_DOWN = 1;
    public static final int DIRECTION_LEFT = 2;
    public static final int DIRECTION_RIGHT = 3;
    public static final int DIRECTION_EXIT = 4;
    public static final int DIRECTION_NONE = 5;

    @IntDef({
            DIRECTION_UP,
            DIRECTION_DOWN,
            DIRECTION_LEFT,
            DIRECTION_RIGHT
            DIRECTION_RIGHT,
            DIRECTION_EXIT,
            DIRECTION_NONE,
    })
    @Retention(RetentionPolicy.SOURCE)
    public @interface ScrollDirection {}
@@ -70,16 +75,12 @@ public class AutoclickScrollPanel {
     */
    public interface ScrollPanelControllerInterface {
        /**
         * Called when a scroll direction is hovered.
         * Called when a button hover state changes.
         *
         * @param direction The direction to scroll: one of {@link ScrollDirection} values.
         * @param direction The direction associated with the button.
         * @param hovered Whether the button is being hovered or not.
         */
        void handleScroll(@ScrollDirection int direction);

        /**
         * Called when the exit button is hovered.
         */
        void exitScrollMode();
        void onHoverButtonChange(@ScrollDirection int direction, boolean hovered);
    }

    public AutoclickScrollPanel(Context context, WindowManager windowManager,
@@ -104,19 +105,12 @@ public class AutoclickScrollPanel {
     * Sets up hover listeners for scroll panel buttons.
     */
    private void initializeButtonState() {
        // Set up hover listeners for direction buttons.
        setupHoverListenerForDirectionButton(mUpButton, DIRECTION_UP);
        setupHoverListenerForDirectionButton(mLeftButton, DIRECTION_LEFT);
        setupHoverListenerForDirectionButton(mRightButton, DIRECTION_RIGHT);
        setupHoverListenerForDirectionButton(mDownButton, DIRECTION_DOWN);

        // Set up hover listener for exit button.
        mExitButton.setOnHoverListener((v, event) -> {
            if (mScrollPanelController != null) {
                mScrollPanelController.exitScrollMode();
            }
            return true;
        });
        // Set up hover listeners for all buttons.
        setupHoverListenerForButton(mUpButton, DIRECTION_UP);
        setupHoverListenerForButton(mLeftButton, DIRECTION_LEFT);
        setupHoverListenerForButton(mRightButton, DIRECTION_RIGHT);
        setupHoverListenerForButton(mDownButton, DIRECTION_DOWN);
        setupHoverListenerForButton(mExitButton, DIRECTION_EXIT);
    }

    /**
@@ -142,14 +136,37 @@ public class AutoclickScrollPanel {
    }

    /**
     * Sets up a hover listener for a direction button.
     * Sets up a hover listener for a button.
     */
    private void setupHoverListenerForDirectionButton(ImageButton button,
            @ScrollDirection int direction) {
    private void setupHoverListenerForButton(ImageButton button, @ScrollDirection int direction) {
        button.setOnHoverListener((v, event) -> {
            if (mScrollPanelController != null) {
                mScrollPanelController.handleScroll(direction);
            if (mScrollPanelController == null) {
                return true;
            }

            boolean hovered;
            switch (event.getAction()) {
                case MotionEvent.ACTION_HOVER_ENTER:
                    hovered = true;
                    break;
                case MotionEvent.ACTION_HOVER_MOVE:
                    // For direction buttons, continuously trigger scroll on hover move.
                    if (direction != DIRECTION_EXIT) {
                        hovered = true;
                    } else {
                        // Ignore hover move events for exit button.
                        return true;
                    }
                    break;
                case MotionEvent.ACTION_HOVER_EXIT:
                    hovered = false;
                    break;
                default:
                    return true;
            }

            // Notify the controller about the hover change.
            mScrollPanelController.onHoverButtonChange(direction, hovered);
            return true;
        });
    }
+96 −22
Original line number Diff line number Diff line
@@ -21,8 +21,12 @@ import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentat
import static com.google.common.truth.Truth.assertThat;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.never;

import android.content.Context;
import android.testing.AndroidTestingRunner;
@@ -125,37 +129,107 @@ public class AutoclickScrollPanelTest {
    }

    @Test
    public void directionButtons_onHover_callsHandleScroll() {
        // Test up button.
        triggerHoverEvent(mUpButton);
        verify(mMockScrollPanelController).handleScroll(AutoclickScrollPanel.DIRECTION_UP);

        // Test down button.
        triggerHoverEvent(mDownButton);
        verify(mMockScrollPanelController).handleScroll(AutoclickScrollPanel.DIRECTION_DOWN);

        // Test left button.
        triggerHoverEvent(mLeftButton);
        verify(mMockScrollPanelController).handleScroll(AutoclickScrollPanel.DIRECTION_LEFT);

        // Test right button.
        triggerHoverEvent(mRightButton);
        verify(mMockScrollPanelController).handleScroll(AutoclickScrollPanel.DIRECTION_RIGHT);
    public void directionButtons_hoverEvents_callsHoverButtonChange() {
        // Test hover enter on direction button.
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_ENTER);
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_UP), eq(/* hovered= */ true));

        // Test hover move.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_UP), eq(/* hovered= */ true));

        // Test hover exit.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_EXIT);
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_UP), eq(/* hovered= */ false));
    }

    @Test
    public void exitButton_onHover_callsExitScrollMode() {
        // Test exit button.
        triggerHoverEvent(mExitButton);
        verify(mMockScrollPanelController).exitScrollMode();
    public void exitButton_hoverEvents_callsHoverButtonChange() {
        // Test hover enter on exit button.
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_ENTER);
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ true));

        // Test hover exit - should call the hover change method with false.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_EXIT);
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ false));

        // Test exit button hover move - should be ignored.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_MOVE);
        verify(mMockScrollPanelController, never()).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), anyBoolean());
    }

    @Test
    public void hoverOnButtonSequence_handledCorrectly() {
        // Test a realistic sequence of events.
        // Case 1. Hover enter on up button, then hover move with in up button twice.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_ENTER);
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
        triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
        verify(mMockScrollPanelController, times(3)).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_UP), eq(true));

        // Case 2. Move from left button to exit button.
        reset(mMockScrollPanelController);
        triggerHoverEvent(mLeftButton, MotionEvent.ACTION_HOVER_ENTER);
        triggerHoverEvent(mLeftButton, MotionEvent.ACTION_HOVER_MOVE);
        triggerHoverEvent(mLeftButton, MotionEvent.ACTION_HOVER_EXIT);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_MOVE);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_ENTER);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_EXIT);

        // Verify left button events - 2 'true' calls (enter+move) and 1 'false' call (exit).
        verify(mMockScrollPanelController, times(2)).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_LEFT), eq(/* hovered= */ true));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_LEFT), eq(/* hovered= */ false));
        // Verify exit button events - hover_move is ignored so 1 'true' call (enter) and 1
        // 'false' call (exit).
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ true));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ false));

        // Case 3. Quick transitions between buttons: left → right → down → exit
        reset(mMockScrollPanelController);
        triggerHoverEvent(mLeftButton, MotionEvent.ACTION_HOVER_EXIT);
        triggerHoverEvent(mRightButton, MotionEvent.ACTION_HOVER_ENTER);
        triggerHoverEvent(mRightButton, MotionEvent.ACTION_HOVER_EXIT);
        triggerHoverEvent(mDownButton, MotionEvent.ACTION_HOVER_ENTER);
        triggerHoverEvent(mDownButton, MotionEvent.ACTION_HOVER_EXIT);
        triggerHoverEvent(mExitButton, MotionEvent.ACTION_HOVER_ENTER);

        // Verify all hover enter/exit events were properly handled
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_LEFT), eq(/* hovered= */ false));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_RIGHT), eq(/* hovered= */ true));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_RIGHT), eq(/* hovered= */ false));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_DOWN), eq(/* hovered= */ true));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_DOWN), eq(/* hovered= */ false));
        verify(mMockScrollPanelController).onHoverButtonChange(
                eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ true));
    }

    // Helper method to simulate a hover event on a view.
    private void triggerHoverEvent(View view) {
    private void triggerHoverEvent(View view, int action) {
        MotionEvent event = MotionEvent.obtain(
                /* downTime= */ 0,
                /* eventTime= */ 0,
                /* action= */ MotionEvent.ACTION_HOVER_ENTER,
                /* action= */ action,
                /* x= */ 0,
                /* y= */ 0,
                /* metaState= */ 0);