From e0bd39818ed644e25170b84091490db4417836eb Mon Sep 17 00:00:00 2001 From: Gavin Williams Date: Fri, 21 Feb 2025 20:01:09 +0000 Subject: 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 --- .../layout/accessibility_autoclick_type_panel.xml | 4 +- .../autoclick/AutoclickController.java | 29 ++++-- .../autoclick/AutoclickLinearLayout.java | 80 ++++++++++++++++ .../autoclick/AutoclickTypePanel.java | 18 +++- .../autoclick/AutoclickControllerTest.java | 89 ++++++++++++++++++ .../autoclick/AutoclickLinearLayoutTest.java | 102 +++++++++++++++++++++ .../autoclick/AutoclickTypePanelTest.java | 43 +++++++++ 7 files changed, 352 insertions(+), 13 deletions(-) create mode 100644 services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickLinearLayout.java create mode 100644 services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickLinearLayoutTest.java diff --git a/core/res/res/layout/accessibility_autoclick_type_panel.xml b/core/res/res/layout/accessibility_autoclick_type_panel.xml index cedbdc175488..902ef7fc38e8 100644 --- a/core/res/res/layout/accessibility_autoclick_type_panel.xml +++ b/core/res/res/layout/accessibility_autoclick_type_panel.xml @@ -17,7 +17,7 @@ */ --> - - + diff --git a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java index bb3c710b0c23..0f6f86b39458 100644 --- a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java +++ b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java @@ -103,12 +103,16 @@ public class AutoclickController extends BaseEventStreamTransformation { @Override public void toggleAutoclickPause(boolean paused) { if (paused) { - if (mClickScheduler != null) { - mClickScheduler.cancel(); - } - if (mAutoclickIndicatorScheduler != null) { - mAutoclickIndicatorScheduler.cancel(); - } + cancelPendingClick(); + } + } + + @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 diff --git a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickLinearLayout.java b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickLinearLayout.java new file mode 100644 index 000000000000..fe8adf75704d --- /dev/null +++ b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickLinearLayout.java @@ -0,0 +1,80 @@ +/* + * 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); + } + } +} diff --git a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickTypePanel.java b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickTypePanel.java index ab4b3b13eece..57bbb4a7a0a7 100644 --- a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickTypePanel.java +++ b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickTypePanel.java @@ -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; } diff --git a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickControllerTest.java b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickControllerTest.java index 17d8882b487c..ea83825cd810 100644 --- a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickControllerTest.java +++ b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickControllerTest.java @@ -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); diff --git a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickLinearLayoutTest.java b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickLinearLayoutTest.java new file mode 100644 index 000000000000..9e629f7c87a2 --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickLinearLayoutTest.java @@ -0,0 +1,102 @@ +/* + * 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 static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import static com.google.common.truth.Truth.assertThat; + +import android.testing.AndroidTestingRunner; +import android.testing.TestableContext; +import android.view.MotionEvent; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** Test cases for {@link AutoclickLinearLayout}. */ +@RunWith(AndroidTestingRunner.class) +public class AutoclickLinearLayoutTest { + private boolean mHovered; + + private final AutoclickLinearLayout.OnHoverChangedListener mListener = + new AutoclickLinearLayout.OnHoverChangedListener() { + @Override + public void onHoverChanged(boolean hovered) { + mHovered = hovered; + } + }; + + @Rule + public TestableContext mTestableContext = + new TestableContext(getInstrumentation().getContext()); + private AutoclickLinearLayout mAutoclickLinearLayout; + + @Before + public void setUp() { + mAutoclickLinearLayout = new AutoclickLinearLayout(mTestableContext); + } + + @Test + public void autoclickLinearLayout_hoverChangedListener_setHovered() { + mHovered = false; + mAutoclickLinearLayout.setOnHoverChangedListener(mListener); + mAutoclickLinearLayout.onHoverChanged(/* hovered= */ true); + assertThat(mHovered).isTrue(); + } + + @Test + public void autoclickLinearLayout_hoverChangedListener_setNotHovered() { + mHovered = true; + + mAutoclickLinearLayout.setOnHoverChangedListener(mListener); + mAutoclickLinearLayout.onHoverChanged(/* hovered= */ false); + assertThat(mHovered).isFalse(); + } + + @Test + public void autoclickLinearLayout_onInterceptHoverEvent_hovered() { + mAutoclickLinearLayout.setHovered(false); + mAutoclickLinearLayout.onInterceptHoverEvent( + getFakeMotionEvent(MotionEvent.ACTION_HOVER_ENTER)); + assertThat(mAutoclickLinearLayout.isHovered()).isTrue(); + + mAutoclickLinearLayout.setHovered(false); + mAutoclickLinearLayout.onInterceptHoverEvent( + getFakeMotionEvent(MotionEvent.ACTION_HOVER_MOVE)); + assertThat(mAutoclickLinearLayout.isHovered()).isTrue(); + } + + @Test + public void autoclickLinearLayout_onInterceptHoverEvent_hoveredExit() { + mAutoclickLinearLayout.setHovered(true); + mAutoclickLinearLayout.onInterceptHoverEvent( + getFakeMotionEvent(MotionEvent.ACTION_HOVER_EXIT)); + assertThat(mAutoclickLinearLayout.isHovered()).isFalse(); + } + + private MotionEvent getFakeMotionEvent(int motionEventAction) { + return MotionEvent.obtain( + /* downTime= */ 0, + /* eventTime= */ 0, + /* action= */ motionEventAction, + /* x= */ 0, + /* y= */ 0, + /* metaState= */ 0); + } +} diff --git a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickTypePanelTest.java b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickTypePanelTest.java index 9e123406dff5..f7b16c808c50 100644 --- a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickTypePanelTest.java +++ b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickTypePanelTest.java @@ -78,6 +78,7 @@ public class AutoclickTypePanelTest { private @AutoclickType int mActiveClickType = AUTOCLICK_TYPE_LEFT_CLICK; private boolean mPaused; + private boolean mHovered; private final ClickPanelControllerInterface clickPanelController = new ClickPanelControllerInterface() { @@ -90,6 +91,11 @@ public class AutoclickTypePanelTest { public void toggleAutoclickPause(boolean paused) { mPaused = paused; } + + @Override + public void onHoverChange(boolean hovered) { + mHovered = hovered; + } }; @Before @@ -412,6 +418,33 @@ public class AutoclickTypePanelTest { upEvent.recycle(); } + @Test + public void hovered_IsHovered() { + AutoclickLinearLayout mContext = mAutoclickTypePanel.getContentViewForTesting(); + + assertThat(mAutoclickTypePanel.isHovered()).isFalse(); + mContext.onInterceptHoverEvent(getFakeMotionHoverMoveEvent()); + assertThat(mAutoclickTypePanel.isHovered()).isTrue(); + } + + @Test + public void hovered_OnHoverChange_isHovered() { + AutoclickLinearLayout mContext = mAutoclickTypePanel.getContentViewForTesting(); + + mHovered = false; + mContext.onHoverChanged(true); + assertThat(mHovered).isTrue(); + } + + @Test + public void hovered_OnHoverChange_isNotHovered() { + AutoclickLinearLayout mContext = mAutoclickTypePanel.getContentViewForTesting(); + + mHovered = true; + mContext.onHoverChanged(false); + assertThat(mHovered).isFalse(); + } + private void verifyButtonHasSelectedStyle(@NonNull LinearLayout button) { GradientDrawable gradientDrawable = (GradientDrawable) button.getBackground(); assertThat(gradientDrawable.getColor().getDefaultColor()) @@ -426,4 +459,14 @@ public class AutoclickTypePanelTest { assertThat(params.x).isEqualTo(expectedPosition[2]); assertThat(params.y).isEqualTo(expectedPosition[3]); } + + private MotionEvent getFakeMotionHoverMoveEvent() { + return MotionEvent.obtain( + /* downTime= */ 0, + /* eventTime= */ 0, + /* action= */ MotionEvent.ACTION_HOVER_MOVE, + /* x= */ 0, + /* y= */ 0, + /* metaState= */ 0); + } } -- cgit v1.2.3-59-g8ed1b