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

Commit e0bd3981 authored by Gavin Williams's avatar Gavin Williams
Browse files

a11y: Resume autoclick when hovering panel

Resume autoclick whenever the panel is hovered to allow the user to get
out of a paused state.

When the panel is a standard LinearLayout,the View api
setOnHoverListener() does not work for this use case becasuse of the
child button elements. When the button elements become hovered, the
LinearLayout panel considers that as an "ACTION_HOVER_EXIT" even though
the button is actually inside the panel.

Creating a subclass of LinearLayout is required to implement
onInterceptHoverEvent(). This allows the event from hovering of any
element part of the "ViewGroup" (i.e. the buttons) to be intercepted and
handled as desired.

Demo: http://b/397460424#comment2

Bug: b/397460424
Test: AutoclickTypePanelTest
Flag: com.android.server.accessibility.enable_autoclick_indicator
Change-Id: I05f8d8e77585086cb8d8933a5df6c9bbd61437bb
parent e5180808
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
@@ -17,7 +17,7 @@
*/
-->

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<com.android.server.accessibility.autoclick.AutoclickLinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/accessibility_autoclick_type_panel"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
@@ -130,4 +130,4 @@

    </LinearLayout>

</LinearLayout>
</com.android.server.accessibility.autoclick.AutoclickLinearLayout>
+21 −8
Original line number Diff line number Diff line
@@ -103,12 +103,16 @@ public class AutoclickController extends BaseEventStreamTransformation {
                @Override
                public void toggleAutoclickPause(boolean paused) {
                    if (paused) {
                        if (mClickScheduler != null) {
                            mClickScheduler.cancel();
                        cancelPendingClick();
                    }
                        if (mAutoclickIndicatorScheduler != null) {
                            mAutoclickIndicatorScheduler.cancel();
                }

                @Override
                public void onHoverChange(boolean hovered) {
                    // Cancel all pending clicks when the mouse moves outside the panel while
                    // autoclick is still paused.
                    if (!hovered && isPaused()) {
                        cancelPendingClick();
                    }
                }
            };
@@ -226,8 +230,17 @@ public class AutoclickController extends BaseEventStreamTransformation {
    }

    private boolean isPaused() {
        // TODO (b/397460424): Unpause when hovering over panel.
        return Flags.enableAutoclickIndicator() && mAutoclickTypePanel.isPaused();
        return Flags.enableAutoclickIndicator() && mAutoclickTypePanel.isPaused()
                && !mAutoclickTypePanel.isHovered();
    }

    private void cancelPendingClick() {
        if (mClickScheduler != null) {
            mClickScheduler.cancel();
        }
        if (mAutoclickIndicatorScheduler != null) {
            mAutoclickIndicatorScheduler.cancel();
        }
    }

    @VisibleForTesting
+80 −0
Original line number Diff line number Diff line
/*
 * Copyright 2025 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.autoclick;

import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.widget.LinearLayout;

/**
 * A custom LinearLayout that provides enhanced hover event handling.
 * This class overrides hover methods to track hover events for the entire panel ViewGroup,
 * including the descendant buttons. This allows for consistent hover behavior and feedback
 * across the entire layout.
 */
public class AutoclickLinearLayout extends LinearLayout {
    public interface OnHoverChangedListener {
        /**
         * Called when the hover state of the AutoclickLinearLayout changes.
         *
         * @param hovered {@code true} if the view is now hovered, {@code false} otherwise.
         */
        void onHoverChanged(boolean hovered);
    }

    private OnHoverChangedListener mListener;

    public AutoclickLinearLayout(Context context) {
        super(context);
    }

    public AutoclickLinearLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public AutoclickLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    public AutoclickLinearLayout(Context context, AttributeSet attrs, int defStyleAttr,
            int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    public void setOnHoverChangedListener(OnHoverChangedListener listener) {
        mListener = listener;
    }

    @Override
    public boolean onInterceptHoverEvent(MotionEvent event) {
        int action = event.getActionMasked();
        setHovered(action == MotionEvent.ACTION_HOVER_ENTER
                || action == MotionEvent.ACTION_HOVER_MOVE);

        return false;
    }

    @Override
    public void onHoverChanged(boolean hovered) {
        super.onHoverChanged(hovered);

        if (mListener != null) {
            mListener.onHoverChanged(hovered);
        }
    }
}
+15 −3
Original line number Diff line number Diff line
@@ -110,11 +110,18 @@ public class AutoclickTypePanel {
         * @param paused {@code true} to pause autoclick, {@code false} to resume.
         */
        void toggleAutoclickPause(boolean paused);

        /**
         * Called when the hovered state of the panel changes.
         *
         * @param hovered {@code true} if the panel is now hovered, {@code false} otherwise.
         */
        void onHoverChange(boolean hovered);
    }

    private final Context mContext;

    private final View mContentView;
    private final AutoclickLinearLayout mContentView;

    private final WindowManager mWindowManager;

@@ -164,8 +171,9 @@ public class AutoclickTypePanel {
                R.drawable.accessibility_autoclick_resume);

        mContentView =
                LayoutInflater.from(context)
                (AutoclickLinearLayout) LayoutInflater.from(context)
                        .inflate(R.layout.accessibility_autoclick_type_panel, null);
        mContentView.setOnHoverChangedListener(mClickPanelController::onHoverChange);
        mLeftClickButton =
                mContentView.findViewById(R.id.accessibility_autoclick_left_click_layout);
        mRightClickButton =
@@ -339,6 +347,10 @@ public class AutoclickTypePanel {
        return mPaused;
    }

    public boolean isHovered() {
        return mContentView.isHovered();
    }

    /** Toggles the panel expanded or collapsed state. */
    private void togglePanelExpansion(@AutoclickType int clickType) {
        final LinearLayout button = getButtonFromClickType(clickType);
@@ -520,7 +532,7 @@ public class AutoclickTypePanel {

    @VisibleForTesting
    @NonNull
    View getContentViewForTesting() {
    AutoclickLinearLayout getContentViewForTesting() {
        return mContentView;
    }

+89 −0
Original line number Diff line number Diff line
@@ -571,6 +571,95 @@ public class AutoclickControllerTest {
        assertThat(mController.mClickScheduler.getScheduledClickTimeForTesting()).isNotEqualTo(-1);
    }

    @Test
    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
    public void pauseButton_panelNotHovered_clickNotTriggeredWhenPaused() {
        injectFakeMouseActionHoverMoveEvent();

        // Pause autoclick and ensure the panel is not hovered.
        AutoclickTypePanel mockAutoclickTypePanel = mock(AutoclickTypePanel.class);
        when(mockAutoclickTypePanel.isPaused()).thenReturn(true);
        when(mockAutoclickTypePanel.isHovered()).thenReturn(false);
        mController.mAutoclickTypePanel = mockAutoclickTypePanel;

        // Send hover move event.
        MotionEvent hoverMove = MotionEvent.obtain(
                /* downTime= */ 0,
                /* eventTime= */ 100,
                /* action= */ MotionEvent.ACTION_HOVER_MOVE,
                /* x= */ 30f,
                /* y= */ 0f,
                /* metaState= */ 0);
        hoverMove.setSource(InputDevice.SOURCE_MOUSE);
        mController.onMotionEvent(hoverMove, hoverMove, /* policyFlags= */ 0);

        // Verify click is not triggered.
        assertThat(mController.mClickScheduler.getIsActiveForTesting()).isFalse();
        assertThat(mController.mClickScheduler.getScheduledClickTimeForTesting()).isEqualTo(-1);
    }

    @Test
    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
    public void pauseButton_panelHovered_clickTriggeredWhenPaused() {
        injectFakeMouseActionHoverMoveEvent();

        // Pause autoclick and hover the panel.
        AutoclickTypePanel mockAutoclickTypePanel = mock(AutoclickTypePanel.class);
        when(mockAutoclickTypePanel.isPaused()).thenReturn(true);
        when(mockAutoclickTypePanel.isHovered()).thenReturn(true);
        mController.mAutoclickTypePanel = mockAutoclickTypePanel;

        // Send hover move event.
        MotionEvent hoverMove = MotionEvent.obtain(
                /* downTime= */ 0,
                /* eventTime= */ 100,
                /* action= */ MotionEvent.ACTION_HOVER_MOVE,
                /* x= */ 30f,
                /* y= */ 0f,
                /* metaState= */ 0);
        hoverMove.setSource(InputDevice.SOURCE_MOUSE);
        mController.onMotionEvent(hoverMove, hoverMove, /* policyFlags= */ 0);

        // Verify click is triggered.
        assertThat(mController.mClickScheduler.getIsActiveForTesting()).isTrue();
        assertThat(mController.mClickScheduler.getScheduledClickTimeForTesting()).isNotEqualTo(-1);
    }

    @Test
    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
    public void pauseButton_unhoveringCancelsClickWhenPaused() {
        injectFakeMouseActionHoverMoveEvent();

        // Pause autoclick and hover the panel.
        AutoclickTypePanel mockAutoclickTypePanel = mock(AutoclickTypePanel.class);
        when(mockAutoclickTypePanel.isPaused()).thenReturn(true);
        when(mockAutoclickTypePanel.isHovered()).thenReturn(true);
        mController.mAutoclickTypePanel = mockAutoclickTypePanel;

        // Send hover move event.
        MotionEvent hoverMove = MotionEvent.obtain(
                /* downTime= */ 0,
                /* eventTime= */ 100,
                /* action= */ MotionEvent.ACTION_HOVER_MOVE,
                /* x= */ 30f,
                /* y= */ 0f,
                /* metaState= */ 0);
        hoverMove.setSource(InputDevice.SOURCE_MOUSE);
        mController.onMotionEvent(hoverMove, hoverMove, /* policyFlags= */ 0);

        // Verify click is triggered.
        assertThat(mController.mClickScheduler.getIsActiveForTesting()).isTrue();
        assertThat(mController.mClickScheduler.getScheduledClickTimeForTesting()).isNotEqualTo(-1);

        // Now simulate the pointer being moved outside the panel.
        when(mockAutoclickTypePanel.isHovered()).thenReturn(false);
        mController.clickPanelController.onHoverChange(/* hovered= */ false);

        // Verify pending click is canceled.
        assertThat(mController.mClickScheduler.getIsActiveForTesting()).isFalse();
        assertThat(mController.mClickScheduler.getScheduledClickTimeForTesting()).isEqualTo(-1);
    }

    private void injectFakeMouseActionHoverMoveEvent() {
        MotionEvent event = getFakeMotionHoverMoveEvent();
        event.setSource(InputDevice.SOURCE_MOUSE);
Loading