a11y: Allow continuously scroll

Video: http://shortn/_mELXo72oHV

Bug: b/409650027
Test: AutoclickScrollPointIndicatorTest
Flag: com.android.server.accessibility.enable_autoclick_indicator
Change-Id: I162f0fd0f8f818e1fddfa787d3e81ef4969678f9
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 d7bc5df..13b2030 100644
--- a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java
+++ b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickController.java
@@ -92,8 +92,10 @@
             (long) (ViewConfiguration.getLongPressTimeout() * 1.5f);
 
     private static final String LOG_TAG = AutoclickController.class.getSimpleName();
-    // TODO(b/393559560): Finalize scroll amount.
-    private static final float SCROLL_AMOUNT = 1.0f;
+    private static final float SCROLL_AMOUNT = 0.5f;
+    protected static final long CONTINUOUS_SCROLL_INTERVAL = 30;
+    private Handler mContinuousScrollHandler;
+    private Runnable mContinuousScrollRunnable;
 
     private final AccessibilityTraceManager mTrace;
     private final Context mContext;
@@ -123,7 +125,8 @@
     private @AutoclickType int mActiveClickType = AUTOCLICK_TYPE_LEFT_CLICK;
 
     // Default scroll direction is DIRECTION_NONE.
-    private @AutoclickScrollPanel.ScrollDirection int mHoveredDirection = DIRECTION_NONE;
+    @VisibleForTesting
+    protected @AutoclickScrollPanel.ScrollDirection int mHoveredDirection = DIRECTION_NONE;
 
     // True during the duration of a dragging event.
     private boolean mDragModeIsDragging = false;
@@ -181,31 +184,22 @@
                     // 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;
-                    }
-
-                    // Handle all non-exit buttons when hovered.
-                    if (hovered) {
-                        // Clear the indicator.
-                        if (mAutoclickIndicatorScheduler != null) {
-                            mAutoclickIndicatorScheduler.cancel();
-                            if (mAutoclickIndicatorView != null) {
-                                mAutoclickIndicatorView.clearIndicator();
-                            }
+                        // For exit button, return early and the autoclick system will handle the
+                        // countdown then exit scroll mode.
+                        if (direction == AutoclickScrollPanel.DIRECTION_EXIT) {
+                            return;
                         }
-                        // Perform scroll action.
+
+                        // For scroll directions, start continuous scrolling.
                         if (direction != DIRECTION_NONE) {
-                            handleScroll(direction);
+                            startContinuousScroll(direction);
                         }
+                    } else if (mHoveredDirection == direction) {
+                        // If not hovered, stop scrolling — but only if the mouse leaves the same
+                        // button that started it. This avoids stopping the scroll when the mouse
+                        // briefly moves over other buttons.
+                        stopContinuousScroll();
                     }
                 }
             };
@@ -267,6 +261,16 @@
         mAutoclickScrollPanel = new AutoclickScrollPanel(mContext, mWindowManager,
                 mScrollPanelController);
 
+        // Initialize continuous scroll handler and runnable.
+        mContinuousScrollHandler = new Handler(handler.getLooper());
+        mContinuousScrollRunnable = new Runnable() {
+            @Override
+            public void run() {
+                handleScroll(mHoveredDirection);
+                mContinuousScrollHandler.postDelayed(this, CONTINUOUS_SCROLL_INTERVAL);
+            }
+        };
+
         mAutoclickTypePanel.show();
         mWindowManager.addView(mAutoclickIndicatorView, mAutoclickIndicatorView.getLayoutParams());
     }
@@ -323,6 +327,11 @@
             mAutoclickScrollPanel.hide();
             mAutoclickScrollPanel = null;
         }
+
+        if (mContinuousScrollHandler != null) {
+            mContinuousScrollHandler.removeCallbacks(mContinuousScrollRunnable);
+            mContinuousScrollHandler = null;
+        }
     }
 
     private void scheduleClick(MotionEvent event, int policyFlags) {
@@ -366,6 +375,14 @@
      * Handles scroll operations in the specified direction.
      */
     private void handleScroll(@AutoclickScrollPanel.ScrollDirection int direction) {
+        // Remove the autoclick indicator view when hovering on directional buttons.
+        if (mAutoclickIndicatorScheduler != null) {
+            mAutoclickIndicatorScheduler.cancel();
+            if (mAutoclickIndicatorView != null) {
+                mAutoclickIndicatorView.clearIndicator();
+            }
+        }
+
         final long now = SystemClock.uptimeMillis();
 
         // Create pointer properties.
@@ -426,8 +443,25 @@
         if (mAutoclickScrollPanel != null) {
             mAutoclickScrollPanel.hide();
         }
+        stopContinuousScroll();
     }
 
+    private void startContinuousScroll(@AutoclickScrollPanel.ScrollDirection int direction) {
+        if (mContinuousScrollHandler != null) {
+            handleScroll(direction);
+            mContinuousScrollHandler.postDelayed(mContinuousScrollRunnable,
+                    CONTINUOUS_SCROLL_INTERVAL);
+        }
+    }
+
+    private void stopContinuousScroll() {
+        if (mContinuousScrollHandler != null) {
+            mContinuousScrollHandler.removeCallbacks(mContinuousScrollRunnable);
+        }
+        mHoveredDirection = DIRECTION_NONE;
+    }
+
+
     @VisibleForTesting
     void onChangeForTesting(boolean selfChange, Uri uri) {
         mAutoclickSettingsObserver.onChange(selfChange, uri);
diff --git a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickScrollPanel.java b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickScrollPanel.java
index f0d0c45..fe111c3 100644
--- a/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickScrollPanel.java
+++ b/services/accessibility/java/com/android/server/accessibility/autoclick/AutoclickScrollPanel.java
@@ -227,15 +227,6 @@
                 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;
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 84320356..f838e9b 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
@@ -18,6 +18,7 @@
 
 import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
 
+import static com.android.server.accessibility.autoclick.AutoclickController.CONTINUOUS_SCROLL_INTERVAL;
 import static com.android.server.accessibility.autoclick.AutoclickTypePanel.AUTOCLICK_TYPE_RIGHT_CLICK;
 import static com.android.server.testutils.MockitoUtilsKt.eq;
 
@@ -67,16 +68,20 @@
 @TestableLooper.RunWithLooper(setAsMainLooper = true)
 public class AutoclickControllerTest {
 
-    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
-    @Rule public final MockitoRule mMockitoRule = MockitoJUnit.rule();
+    @Rule
+    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+    @Rule
+    public final MockitoRule mMockitoRule = MockitoJUnit.rule();
 
     @Rule
     public TestableContext mTestableContext =
             new TestableContext(getInstrumentation().getContext());
 
     private TestableLooper mTestableLooper;
-    @Mock private AccessibilityTraceManager mMockTrace;
-    @Mock private WindowManager mMockWindowManager;
+    @Mock
+    private AccessibilityTraceManager mMockTrace;
+    @Mock
+    private WindowManager mMockWindowManager;
     private AutoclickController mController;
     private MotionEventCaptor mMotionEventCaptor;
 
@@ -1271,6 +1276,62 @@
         assertThat(mController.hasOngoingLongPressForTesting()).isFalse();
     }
 
+    @Test
+    @EnableFlags(com.android.server.accessibility.Flags.FLAG_ENABLE_AUTOCLICK_INDICATOR)
+    public void continuousScroll_completeLifecycle() {
+        // Set up event capturer to track scroll events.
+        ScrollEventCaptor scrollCaptor = new ScrollEventCaptor();
+        mController.setNext(scrollCaptor);
+
+        // Initialize controller.
+        injectFakeMouseActionHoverMoveEvent();
+
+        // Set cursor position.
+        float expectedX = 100f;
+        float expectedY = 200f;
+        mController.mScrollCursorX = expectedX;
+        mController.mScrollCursorY = expectedY;
+
+        // Start scrolling by hovering UP button.
+        mController.mScrollPanelController.onHoverButtonChange(
+                AutoclickScrollPanel.DIRECTION_UP, true);
+
+        // Verify initial hover state and event.
+        assertThat(mController.mHoveredDirection).isEqualTo(AutoclickScrollPanel.DIRECTION_UP);
+        assertThat(scrollCaptor.eventCount).isEqualTo(1);
+        assertThat(scrollCaptor.scrollEvent.getAxisValue(MotionEvent.AXIS_VSCROLL)).isGreaterThan(
+                0);
+
+        // Simulate continuous scrolling by triggering runnable.
+        scrollCaptor.eventCount = 0;
+
+        // Advance time by CONTINUOUS_SCROLL_INTERVAL (30ms) and process messages.
+        mTestableLooper.moveTimeForward(CONTINUOUS_SCROLL_INTERVAL);
+        mTestableLooper.processAllMessages();
+
+        // Advance time again to trigger second scroll event.
+        mTestableLooper.moveTimeForward(CONTINUOUS_SCROLL_INTERVAL);
+        mTestableLooper.processAllMessages();
+
+        // Verify multiple scroll events were generated.
+        assertThat(scrollCaptor.eventCount).isEqualTo(2);
+        assertThat(scrollCaptor.scrollEvent.getX()).isEqualTo(expectedX);
+        assertThat(scrollCaptor.scrollEvent.getY()).isEqualTo(expectedY);
+
+        // Stop scrolling by un-hovering the button.
+        mController.mScrollPanelController.onHoverButtonChange(
+                AutoclickScrollPanel.DIRECTION_UP, false);
+
+        // Verify direction is reset.
+        assertThat(mController.mHoveredDirection).isEqualTo(AutoclickScrollPanel.DIRECTION_NONE);
+
+        // Verify no more scroll events are generated after stopping.
+        int countBeforeRunnable = scrollCaptor.eventCount;
+        mTestableLooper.moveTimeForward(CONTINUOUS_SCROLL_INTERVAL);
+        mTestableLooper.processAllMessages();
+        assertThat(scrollCaptor.eventCount).isEqualTo(countBeforeRunnable);
+    }
+
     /**
      * =========================================================================
      * Helper Functions
diff --git a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickScrollPanelTest.java b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickScrollPanelTest.java
index f460c8c..94ae105 100644
--- a/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickScrollPanelTest.java
+++ b/services/tests/servicestests/src/com/android/server/accessibility/autoclick/AutoclickScrollPanelTest.java
@@ -146,7 +146,7 @@
         // Test hover move.
         reset(mMockScrollPanelController);
         triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
-        verify(mMockScrollPanelController).onHoverButtonChange(
+        verify(mMockScrollPanelController, never()).onHoverButtonChange(
                 eq(AutoclickScrollPanel.DIRECTION_UP), eq(/* hovered= */ true));
 
         // Test hover exit.
@@ -184,7 +184,7 @@
         triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_ENTER);
         triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
         triggerHoverEvent(mUpButton, MotionEvent.ACTION_HOVER_MOVE);
-        verify(mMockScrollPanelController, times(3)).onHoverButtonChange(
+        verify(mMockScrollPanelController, times(1)).onHoverButtonChange(
                 eq(AutoclickScrollPanel.DIRECTION_UP), eq(true));
 
         // Case 2. Move from left button to exit button.
@@ -192,17 +192,17 @@
         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_MOVE);
         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(
+        // Verify left button events - 1 'true' call (enter) and 1 'false' call (exit).
+        verify(mMockScrollPanelController, times(1)).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 exit button events - 1 'true' call (enter) and 1 'false' call (exit).
         verify(mMockScrollPanelController).onHoverButtonChange(
                 eq(AutoclickScrollPanel.DIRECTION_EXIT), eq(/* hovered= */ true));
         verify(mMockScrollPanelController).onHoverButtonChange(