Merge "Dynamically disable View-based rotary haptics" into main
diff --git a/core/java/android/view/HapticScrollFeedbackProvider.java b/core/java/android/view/HapticScrollFeedbackProvider.java
index 0001176..c3fb855 100644
--- a/core/java/android/view/HapticScrollFeedbackProvider.java
+++ b/core/java/android/view/HapticScrollFeedbackProvider.java
@@ -16,6 +16,8 @@
 
 package android.view;
 
+import static android.view.flags.Flags.dynamicViewRotaryHapticsConfiguration;
+
 import android.annotation.NonNull;
 
 import com.android.internal.annotations.VisibleForTesting;
@@ -41,13 +43,8 @@
 
     private final View mView;
     private final ViewConfiguration mViewConfig;
-    /**
-     * Flag to disable the logic in this class if the View-based scroll haptics implementation is
-     * enabled. If {@code false}, this class will continue to run despite the View's scroll
-     * haptics implementation being enabled. This value should be set to {@code true} when this
-     * class is directly used by the View class.
-     */
-    private final boolean mDisabledIfViewPlaysScrollHaptics;
+    /** Whether or not this provider is being used directly by the View class. */
+    private final boolean mIsFromView;
 
 
     // Info about the cause of the latest scroll event.
@@ -65,17 +62,23 @@
     private boolean mHapticScrollFeedbackEnabled = false;
 
     public HapticScrollFeedbackProvider(@NonNull View view) {
-        this(view, ViewConfiguration.get(view.getContext()),
-                /* disabledIfViewPlaysScrollHaptics= */ true);
+        this(view, ViewConfiguration.get(view.getContext()), /* isFromView= */ false);
     }
 
     /** @hide */
     @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
     public HapticScrollFeedbackProvider(
-            View view, ViewConfiguration viewConfig, boolean disabledIfViewPlaysScrollHaptics) {
+            View view, ViewConfiguration viewConfig, boolean isFromView) {
         mView = view;
         mViewConfig = viewConfig;
-        mDisabledIfViewPlaysScrollHaptics = disabledIfViewPlaysScrollHaptics;
+        mIsFromView = isFromView;
+        if (dynamicViewRotaryHapticsConfiguration() && !isFromView) {
+            // Disable the View class's rotary scroll feedback logic if this provider is not being
+            // directly used by the View class. This is to avoid double rotary scroll feedback:
+            // one from the View class, and one from this provider instance (i.e. mute the View
+            // class's rotary feedback and enable this provider).
+            view.disableRotaryScrollFeedback();
+        }
     }
 
     @Override
@@ -151,7 +154,8 @@
             mAxis = axis;
             mDeviceId = deviceId;
 
-            if (mDisabledIfViewPlaysScrollHaptics
+            if (!dynamicViewRotaryHapticsConfiguration()
+                    && !mIsFromView
                     && (source == InputDevice.SOURCE_ROTARY_ENCODER)
                     && mViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) {
                 mHapticScrollFeedbackEnabled = false;
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index c71bf4b..049189f 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -16754,9 +16754,7 @@
                 mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_DETERMINED;
             }
         }
-        final boolean processForRotaryScrollHaptics =
-                isRotaryEncoderEvent && ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_ENABLED) != 0);
-        if (processForRotaryScrollHaptics) {
+        if (isRotaryEncoderEvent && ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_ENABLED) != 0)) {
             mPrivateFlags4 &= ~PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT;
             mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT;
         }
@@ -16773,7 +16771,10 @@
         // Process scroll haptics after `onGenericMotionEvent`, since that's where scrolling usually
         // happens. Some views may return false from `onGenericMotionEvent` even if they have done
         // scrolling, so disregard the return value when processing for scroll haptics.
-        if (processForRotaryScrollHaptics) {
+        // Check for `PFLAG4_ROTARY_HAPTICS_ENABLED` again, because the View implementation may
+        // call `disableRotaryScrollFeedback` in `onGenericMotionEvent`, which could change the
+        // value of `PFLAG4_ROTARY_HAPTICS_ENABLED`.
+        if (isRotaryEncoderEvent && ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_ENABLED) != 0)) {
             if ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT) != 0) {
                 doRotaryProgressForScrollHaptics(event);
             } else {
@@ -18716,7 +18717,7 @@
     private HapticScrollFeedbackProvider getScrollFeedbackProvider() {
         if (mScrollFeedbackProvider == null) {
             mScrollFeedbackProvider = new HapticScrollFeedbackProvider(this,
-                    ViewConfiguration.get(mContext), /* disabledIfViewPlaysScrollHaptics= */ false);
+                    ViewConfiguration.get(mContext), /* isFromView= */ true);
         }
         return mScrollFeedbackProvider;
     }
@@ -18746,6 +18747,21 @@
     }
 
     /**
+     * Disables the rotary scroll feedback implementation of the View class.
+     *
+     * <p>Note that this does NOT disable all rotary scroll feedback; it just disables the logic
+     * implemented within the View class. The child implementation of the View may implement its own
+     * rotary scroll feedback logic or use {@link ScrollFeedbackProvider} to generate rotary scroll
+     * feedback.
+     */
+    void disableRotaryScrollFeedback() {
+        // Force set PFLAG4_ROTARY_HAPTICS_DETERMINED to avoid recalculating
+        // PFLAG4_ROTARY_HAPTICS_ENABLED under any circumstance.
+        mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_DETERMINED;
+        mPrivateFlags4 &= ~PFLAG4_ROTARY_HAPTICS_ENABLED;
+    }
+
+    /**
      * This is called in response to an internal scroll in this view (i.e., the
      * view scrolled its own contents). This is typically as a result of
      * {@link #scrollBy(int, int)} or {@link #scrollTo(int, int)} having been
diff --git a/core/java/android/view/flags/scroll_feedback_flags.aconfig b/core/java/android/view/flags/scroll_feedback_flags.aconfig
index 658aa29..b180e58 100644
--- a/core/java/android/view/flags/scroll_feedback_flags.aconfig
+++ b/core/java/android/view/flags/scroll_feedback_flags.aconfig
@@ -23,3 +23,10 @@
     bug: "331830899"
     is_fixed_read_only: true
 }
+
+flag {
+    namespace: "wear_frameworks"
+    name: "dynamic_view_rotary_haptics_configuration"
+    description: "Whether ScrollFeedbackProvider dynamically disables View-based rotary haptics."
+    bug: "377998870 "
+}
diff --git a/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java b/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
index ac6c19e..66cf9c7 100644
--- a/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
+++ b/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
@@ -17,6 +17,7 @@
 package android.view;
 
 import static android.os.vibrator.Flags.FLAG_HAPTIC_FEEDBACK_INPUT_SOURCE_CUSTOMIZATION_ENABLED;
+import static android.view.flags.Flags.FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION;
 import static android.view.HapticFeedbackConstants.SCROLL_ITEM_FOCUS;
 import static android.view.HapticFeedbackConstants.SCROLL_LIMIT;
 import static android.view.HapticFeedbackConstants.SCROLL_TICK;
@@ -74,12 +75,13 @@
 
         mView = new TestView(InstrumentationRegistry.getContext());
         mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
-                /* disabledIfViewPlaysScrollHaptics= */ true);
+                /* isFromView= */ false);
         mSetFlagsRule.disableFlags(FLAG_HAPTIC_FEEDBACK_INPUT_SOURCE_CUSTOMIZATION_ENABLED);
     }
 
     @Test
     public void testRotaryEncoder_noFeedbackWhenViewBasedFeedbackIsEnabled() {
+        mSetFlagsRule.disableFlags(FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION);
         when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
                 .thenReturn(true);
         setHapticScrollTickInterval(5);
@@ -97,7 +99,24 @@
     }
 
     @Test
+    public void testRotaryEncoder_dynamicViewRotaryFeedback_enabledEvenWhenViewFeedbackIsEnabled() {
+        mSetFlagsRule.enableFlags(FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION);
+        when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
+                .thenReturn(true);
+        setHapticScrollTickInterval(5);
+        mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
+                /* isFromView= */ false);
+
+        mProvider.onScrollProgress(
+                INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
+                /* deltaInPixels= */ 10);
+
+        assertFeedbackCount(mView, SCROLL_TICK, 1);
+    }
+
+    @Test
     public void testRotaryEncoder_inputDeviceCustomized_noFeedbackWhenViewBasedFeedbackIsEnabled() {
+        mSetFlagsRule.disableFlags(FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION);
         mSetFlagsRule.enableFlags(FLAG_HAPTIC_FEEDBACK_INPUT_SOURCE_CUSTOMIZATION_ENABLED);
 
         when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
@@ -119,7 +138,7 @@
     @Test
     public void testRotaryEncoder_feedbackWhenDisregardingViewBasedScrollHaptics() {
         mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
-                /* disabledIfViewPlaysScrollHaptics= */ false);
+                /* isFromView= */ true);
         when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
                 .thenReturn(true);
         setHapticScrollTickInterval(5);
@@ -144,7 +163,7 @@
         List<HapticFeedbackRequest> requests = new ArrayList<>();
 
         mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
-                /* disabledIfViewPlaysScrollHaptics= */ false);
+                /* isFromView= */ true);
         when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
                 .thenReturn(true);
         setHapticScrollTickInterval(5);
@@ -917,19 +936,20 @@
 
     @Test
     public void testNonRotaryInputFeedbackNotBlockedByRotaryUnavailability() {
+        mSetFlagsRule.disableFlags(FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION);
         when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
                 .thenReturn(true);
         setHapticScrollFeedbackEnabled(true);
         setHapticScrollTickInterval(5);
         mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
-                /* disabledIfViewPlaysScrollHaptics= */ true);
+                /* isFromView= */ false);
 
         // Expect one feedback here. Touch input should provide feedback since scroll feedback has
         // been enabled via `setHapticScrollFeedbackEnabled(true)`.
         mProvider.onScrollProgress(
                 INPUT_DEVICE_1, InputDevice.SOURCE_TOUCHSCREEN, MotionEvent.AXIS_Y,
                 /* deltaInPixels= */ 10);
-        // Because `isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()` is false and
+        // Because `isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()` is true and
         // `disabledIfViewPlaysScrollHaptics` is true, the scroll progress from rotary encoders will
         // produce no feedback.
         mProvider.onScrollProgress(
diff --git a/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java b/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java
index 9a5c1c5..b1a5637 100644
--- a/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java
+++ b/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java
@@ -30,8 +30,12 @@
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.verify;
 
+import android.annotation.Nullable;
 import android.content.Context;
+import android.platform.test.annotations.EnableFlags;
 import android.platform.test.annotations.Presubmit;
+import android.platform.test.flag.junit.SetFlagsRule;
+import android.view.flags.Flags;
 
 import androidx.test.InstrumentationRegistry;
 import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -39,6 +43,7 @@
 
 import org.junit.After;
 import org.junit.Before;
+import org.junit.Rule;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
@@ -48,6 +53,8 @@
 @RunWith(AndroidJUnit4.class)
 @Presubmit
 public final class RotaryScrollHapticsTest {
+    @Rule public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
+
     private static final int TEST_ROTARY_DEVICE_ID = 1;
     private static final int TEST_RANDOM_DEVICE_ID = 2;
 
@@ -167,6 +174,26 @@
     }
 
     @Test
+    @EnableFlags(Flags.FLAG_DYNAMIC_VIEW_ROTARY_HAPTICS_CONFIGURATION)
+    public void testChildViewImplementationUsesScrollFeedbackProvider_doesNoScrollFeedback() {
+        mView.configureGenericMotion(/* result= */ false, /* scroll= */ true);
+        mView.mUsesCustomScrollFeedbackProvider = true;
+
+        // Send multiple generic motion events, to catch bugs where the behavior is WAI only for the
+        // first dispatch, but buggy for future calls.
+        mView.dispatchGenericMotionEvent(createRotaryEvent(20));
+        mView.dispatchGenericMotionEvent(createRotaryEvent(10));
+        mView.dispatchGenericMotionEvent(createRotaryEvent(30));
+
+        // Verify that the base View class's ScrollFeedbackProvider produces no scroll progress
+        // or limit events, because there's a custom ScrollFeedbackProvider used by the child
+        // View class implementation, which should hint the base View class to disable its own
+        // ScrollFeedbackProvider usage.
+        verifyNoScrollProgress();
+        verifyNoScrollLimit();
+    }
+
+    @Test
     public void testScrollProgress_genericMotionEventCallbackReturningTrue_doesScrollProgress() {
         mView.configureGenericMotion(/* result= */ true, /* scroll= */ true);
 
@@ -208,6 +235,9 @@
     private static final class TestGenericMotionEventControllingView extends View {
         private boolean mGenericMotionResult;
         private boolean mScrollOnGenericMotion;
+        private boolean mUsesCustomScrollFeedbackProvider = false;
+
+        @Nullable private ScrollFeedbackProvider mCustomScrollFeedbackProvider;
 
         TestGenericMotionEventControllingView(Context context) {
             super(context);
@@ -222,6 +252,19 @@
         public boolean onGenericMotionEvent(MotionEvent event) {
             if (mScrollOnGenericMotion) {
                 scrollTo(100, 200); // scroll values random (not relevant for tests).
+                if (mUsesCustomScrollFeedbackProvider) {
+                    // Mimic how a real child class of View would instantiate and use the
+                    // ScrollFeedbackProvider API.
+                    if (mCustomScrollFeedbackProvider == null) {
+                        mCustomScrollFeedbackProvider = ScrollFeedbackProvider.createProvider(this);
+                    }
+                    float axisScrollValue = event.getAxisValue(AXIS_SCROLL);
+                    mCustomScrollFeedbackProvider.onScrollProgress(
+                            event.getDeviceId(),
+                            event.getSource(),
+                            MotionEvent.AXIS_SCROLL,
+                            (int) (axisScrollValue * TEST_SCALED_VERTICAL_SCROLL_FACTOR));
+                }
             }
             return mGenericMotionResult;
         }