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;
}