summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apct-tests/perftests/core/src/android/view/ViewConfigurationPerfTest.java61
-rw-r--r--core/java/android/view/HapticScrollFeedbackProvider.java25
-rw-r--r--core/java/android/view/View.java94
-rw-r--r--core/java/android/view/ViewConfiguration.java51
-rw-r--r--core/java/android/view/flags/scroll_feedback_flags.aconfig7
-rw-r--r--core/res/res/values/config.xml3
-rw-r--r--core/res/res/values/symbols.xml1
-rw-r--r--core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java107
-rw-r--r--core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java259
9 files changed, 581 insertions, 27 deletions
diff --git a/apct-tests/perftests/core/src/android/view/ViewConfigurationPerfTest.java b/apct-tests/perftests/core/src/android/view/ViewConfigurationPerfTest.java
new file mode 100644
index 000000000000..7a7250b9e910
--- /dev/null
+++ b/apct-tests/perftests/core/src/android/view/ViewConfigurationPerfTest.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2023 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 android.view;
+
+import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
+
+import android.content.Context;
+
+import androidx.benchmark.BenchmarkState;
+import androidx.benchmark.junit4.BenchmarkRule;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Rule;
+import org.junit.Test;
+
+@SmallTest
+public class ViewConfigurationPerfTest {
+ @Rule
+ public final BenchmarkRule mBenchmarkRule = new BenchmarkRule();
+
+ private final Context mContext = getInstrumentation().getTargetContext();
+
+ @Test
+ public void testGet_newViewConfiguration() {
+ final BenchmarkState state = mBenchmarkRule.getState();
+
+ while (state.keepRunning()) {
+ state.pauseTiming();
+ // Reset cache so that `ViewConfiguration#get` creates a new instance.
+ ViewConfiguration.resetCacheForTesting();
+ state.resumeTiming();
+
+ ViewConfiguration.get(mContext);
+ }
+ }
+
+ @Test
+ public void testGet_cachedViewConfiguration() {
+ final BenchmarkState state = mBenchmarkRule.getState();
+ // Do `get` once to make sure there's something cached.
+ ViewConfiguration.get(mContext);
+
+ while (state.keepRunning()) {
+ ViewConfiguration.get(mContext);
+ }
+ }
+}
diff --git a/core/java/android/view/HapticScrollFeedbackProvider.java b/core/java/android/view/HapticScrollFeedbackProvider.java
index a2f1d37c2c11..1310b0ccd3a9 100644
--- a/core/java/android/view/HapticScrollFeedbackProvider.java
+++ b/core/java/android/view/HapticScrollFeedbackProvider.java
@@ -47,9 +47,17 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {
public @interface HapticScrollFeedbackAxis {}
private static final int TICK_INTERVAL_NO_TICK = 0;
+ private static final boolean INITIAL_END_OF_LIST_HAPTICS_ENABLED = false;
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;
// Info about the cause of the latest scroll event.
@@ -63,18 +71,21 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {
/** The tick interval corresponding to the current InputDevice/source/axis. */
private int mTickIntervalPixels = TICK_INTERVAL_NO_TICK;
private int mTotalScrollPixels = 0;
- private boolean mCanPlayLimitFeedback = true;
+ private boolean mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED;
private boolean mHapticScrollFeedbackEnabled = false;
public HapticScrollFeedbackProvider(@NonNull View view) {
- this(view, ViewConfiguration.get(view.getContext()));
+ this(view, ViewConfiguration.get(view.getContext()),
+ /* disabledIfViewPlaysScrollHaptics= */ true);
}
/** @hide */
@VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
- public HapticScrollFeedbackProvider(View view, ViewConfiguration viewConfig) {
+ public HapticScrollFeedbackProvider(
+ View view, ViewConfiguration viewConfig, boolean disabledIfViewPlaysScrollHaptics) {
mView = view;
mViewConfig = viewConfig;
+ mDisabledIfViewPlaysScrollHaptics = disabledIfViewPlaysScrollHaptics;
}
@Override
@@ -136,13 +147,19 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {
private void maybeUpdateCurrentConfig(int deviceId, int source, int axis) {
if (mAxis != axis || mSource != source || mDeviceId != deviceId) {
+ if (mDisabledIfViewPlaysScrollHaptics
+ && (source == InputDevice.SOURCE_ROTARY_ENCODER)
+ && mViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) {
+ mHapticScrollFeedbackEnabled = false;
+ return;
+ }
mSource = source;
mAxis = axis;
mDeviceId = deviceId;
mHapticScrollFeedbackEnabled =
mViewConfig.isHapticScrollFeedbackEnabled(deviceId, axis, source);
- mCanPlayLimitFeedback = true;
+ mCanPlayLimitFeedback = INITIAL_END_OF_LIST_HAPTICS_ENABLED;
mTotalScrollPixels = 0;
updateTickIntervals(deviceId, source, axis);
}
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index 4d53b2c6b821..55374b994cd4 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -919,6 +919,9 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
*/
private static boolean sCompatibilityDone = false;
+ /** @hide */
+ public HapticScrollFeedbackProvider mScrollFeedbackProvider = null;
+
/**
* Use the old (broken) way of building MeasureSpecs.
*/
@@ -3605,6 +3608,10 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
* 1 PFLAG4_IMPORTANT_FOR_CREDENTIAL_MANAGER
* 1 PFLAG4_TRAVERSAL_TRACING_ENABLED
* 1 PFLAG4_RELAYOUT_TRACING_ENABLED
+ * 1 PFLAG4_ROTARY_HAPTICS_DETERMINED
+ * 1 PFLAG4_ROTARY_HAPTICS_ENABLED
+ * 1 PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT
+ * 1 PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT
* |-------|-------|-------|-------|
*/
@@ -3703,6 +3710,24 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
*/
private static final int PFLAG4_RELAYOUT_TRACING_ENABLED = 0x000080000;
+ /** Indicates if rotary scroll haptics support for the view has been determined. */
+ private static final int PFLAG4_ROTARY_HAPTICS_DETERMINED = 0x100000;
+
+ /**
+ * Indicates if rotary scroll haptics is enabled for this view.
+ * The source of truth for this info is a ViewConfiguration API; this bit only caches the value.
+ */
+ private static final int PFLAG4_ROTARY_HAPTICS_ENABLED = 0x200000;
+
+ /** Indicates if there has been a scroll event since the last rotary input. */
+ private static final int PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT = 0x400000;
+
+ /**
+ * Indicates if there has been a rotary input that may generate a scroll event.
+ * This flag is important so that a scroll event can be properly attributed to a rotary input.
+ */
+ private static final int PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT = 0x800000;
+
/* End of masks for mPrivateFlags4 */
/** @hide */
@@ -15894,6 +15919,28 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
}
private boolean dispatchGenericMotionEventInternal(MotionEvent event) {
+ final boolean isRotaryEncoderEvent = event.isFromSource(InputDevice.SOURCE_ROTARY_ENCODER);
+ if (isRotaryEncoderEvent) {
+ // Determine and cache rotary scroll haptics support if it's not yet determined.
+ // Caching the support is important for two reasons:
+ // 1) Limits call to `ViewConfiguration#get`, which we should avoid if possible.
+ // 2) Limits latency from the `ViewConfiguration` API, which may be slow due to feature
+ // flag querying.
+ if ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_DETERMINED) == 0) {
+ if (ViewConfiguration.get(mContext)
+ .isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) {
+ mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_ENABLED;
+ }
+ mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_DETERMINED;
+ }
+ }
+ final boolean processForRotaryScrollHaptics =
+ isRotaryEncoderEvent && ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_ENABLED) != 0);
+ if (processForRotaryScrollHaptics) {
+ mPrivateFlags4 &= ~PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT;
+ mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT;
+ }
+
//noinspection SimplifiableIfStatement
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnGenericMotionListener != null
@@ -15902,7 +15949,18 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
return true;
}
- if (onGenericMotionEvent(event)) {
+ final boolean onGenericMotionEventResult = onGenericMotionEvent(event);
+ // 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) {
+ if ((mPrivateFlags4 & PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT) != 0) {
+ doRotaryProgressForScrollHaptics(event);
+ } else {
+ doRotaryLimitForScrollHaptics(event);
+ }
+ }
+ if (onGenericMotionEventResult) {
return true;
}
@@ -17783,6 +17841,38 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
}
}
+ private HapticScrollFeedbackProvider getScrollFeedbackProvider() {
+ if (mScrollFeedbackProvider == null) {
+ mScrollFeedbackProvider = new HapticScrollFeedbackProvider(this,
+ ViewConfiguration.get(mContext), /* disabledIfViewPlaysScrollHaptics= */ false);
+ }
+ return mScrollFeedbackProvider;
+ }
+
+ private void doRotaryProgressForScrollHaptics(MotionEvent rotaryEvent) {
+ final float axisScrollValue = rotaryEvent.getAxisValue(MotionEvent.AXIS_SCROLL);
+ final float verticalScrollFactor =
+ ViewConfiguration.get(mContext).getScaledVerticalScrollFactor();
+ final int scrollAmount = -Math.round(axisScrollValue * verticalScrollFactor);
+ getScrollFeedbackProvider().onScrollProgress(
+ rotaryEvent.getDeviceId(), InputDevice.SOURCE_ROTARY_ENCODER,
+ MotionEvent.AXIS_SCROLL, scrollAmount);
+ }
+
+ private void doRotaryLimitForScrollHaptics(MotionEvent rotaryEvent) {
+ final boolean isStart = rotaryEvent.getAxisValue(MotionEvent.AXIS_SCROLL) > 0;
+ getScrollFeedbackProvider().onScrollLimit(
+ rotaryEvent.getDeviceId(), InputDevice.SOURCE_ROTARY_ENCODER,
+ MotionEvent.AXIS_SCROLL, isStart);
+ }
+
+ private void processScrollEventForRotaryEncoderHaptics() {
+ if ((mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT) != 0) {
+ mPrivateFlags4 |= PFLAG4_ROTARY_HAPTICS_SCROLL_SINCE_LAST_ROTARY_INPUT;
+ mPrivateFlags4 &= ~PFLAG4_ROTARY_HAPTICS_WAITING_FOR_SCROLL_EVENT;
+ }
+ }
+
/**
* 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
@@ -17798,6 +17888,8 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
notifySubtreeAccessibilityStateChangedIfNeeded();
postSendViewScrolledAccessibilityEventCallback(l - oldl, t - oldt);
+ processScrollEventForRotaryEncoderHaptics();
+
mBackgroundSizeChanged = true;
mDefaultFocusHighlightSizeChanged = true;
if (mForegroundInfo != null) {
diff --git a/core/java/android/view/ViewConfiguration.java b/core/java/android/view/ViewConfiguration.java
index a3ae6cf20725..2cf5d5d63596 100644
--- a/core/java/android/view/ViewConfiguration.java
+++ b/core/java/android/view/ViewConfiguration.java
@@ -40,6 +40,8 @@ import android.util.SparseArray;
import android.util.TypedValue;
import android.view.flags.Flags;
+import com.android.internal.annotations.VisibleForTesting;
+
/**
* Contains methods to standard constants used in the UI for timeouts, sizes, and distances.
*/
@@ -375,6 +377,7 @@ public class ViewConfiguration {
private final int mSmartSelectionInitializedTimeout;
private final int mSmartSelectionInitializingTimeout;
private final boolean mPreferKeepClearForFocusEnabled;
+ private final boolean mViewBasedRotaryEncoderScrollHapticsEnabledConfig;
@UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.P, trackingBug = 123768915)
private boolean sHasPermanentMenuKey;
@@ -399,6 +402,7 @@ public class ViewConfiguration {
mMaximumRotaryEncoderFlingVelocity = MAXIMUM_FLING_VELOCITY;
mRotaryEncoderHapticScrollFeedbackEnabled = false;
mRotaryEncoderHapticScrollFeedbackTickIntervalPixels = NO_HAPTIC_SCROLL_TICK_INTERVAL;
+ mViewBasedRotaryEncoderScrollHapticsEnabledConfig = false;
mScrollbarSize = SCROLL_BAR_SIZE;
mTouchSlop = TOUCH_SLOP;
mHandwritingSlop = HANDWRITING_SLOP;
@@ -575,6 +579,9 @@ public class ViewConfiguration {
com.android.internal.R.integer.config_smartSelectionInitializingTimeoutMillis);
mPreferKeepClearForFocusEnabled = res.getBoolean(
com.android.internal.R.bool.config_preferKeepClearForFocus);
+ mViewBasedRotaryEncoderScrollHapticsEnabledConfig =
+ res.getBoolean(
+ com.android.internal.R.bool.config_viewBasedRotaryEncoderHapticsEnabled);
}
/**
@@ -590,8 +597,7 @@ public class ViewConfiguration {
public static ViewConfiguration get(@NonNull @UiContext Context context) {
StrictMode.assertConfigurationContext(context, "ViewConfiguration");
- final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
- final int density = (int) (100.0f * metrics.density);
+ final int density = getDisplayDensity(context);
ViewConfiguration configuration = sConfigurations.get(density);
if (configuration == null) {
@@ -603,6 +609,28 @@ public class ViewConfiguration {
}
/**
+ * Removes cached ViewConfiguration instances, so that we can ensure `get` constructs a new
+ * ViewConfiguration instance. This is useful for testing the behavior and performance of
+ * creating ViewConfiguration the first time.
+ *
+ * @hide
+ */
+ @VisibleForTesting
+ public static void resetCacheForTesting() {
+ sConfigurations.clear();
+ }
+
+ /**
+ * Sets the ViewConfiguration cached instanc for a given Context for testing.
+ *
+ * @hide
+ */
+ @VisibleForTesting
+ public static void setInstanceForTesting(Context context, ViewConfiguration instance) {
+ sConfigurations.put(getDisplayDensity(context), instance);
+ }
+
+ /**
* @return The width of the horizontal scrollbar and the height of the vertical
* scrollbar in dips
*
@@ -1311,6 +1339,20 @@ public class ViewConfiguration {
return NO_HAPTIC_SCROLL_TICK_INTERVAL;
}
+ /**
+ * Checks if the View-based haptic scroll feedback implementation is enabled for
+ * {@link InputDevice#SOURCE_ROTARY_ENCODER}s.
+ *
+ * <p>If this method returns {@code true}, the {@link HapticScrollFeedbackProvider} will be
+ * muted for rotary encoders in favor of View's scroll haptics implementation.
+ *
+ * @hide
+ */
+ public boolean isViewBasedRotaryEncoderHapticScrollFeedbackEnabled() {
+ return mViewBasedRotaryEncoderScrollHapticsEnabledConfig
+ && Flags.useViewBasedRotaryEncoderScrollHaptics();
+ }
+
private static boolean isInputDeviceInfoValid(int id, int axis, int source) {
InputDevice device = InputManagerGlobal.getInstance().getInputDevice(id);
return device != null && device.getMotionRange(axis, source) != null;
@@ -1420,4 +1462,9 @@ public class ViewConfiguration {
public static int getHoverTooltipHideShortTimeout() {
return HOVER_TOOLTIP_HIDE_SHORT_TIMEOUT;
}
+
+ private static final int getDisplayDensity(Context context) {
+ final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
+ return (int) (100.0f * metrics.density);
+ }
}
diff --git a/core/java/android/view/flags/scroll_feedback_flags.aconfig b/core/java/android/view/flags/scroll_feedback_flags.aconfig
index 62c569152ee9..d1d871c2dbda 100644
--- a/core/java/android/view/flags/scroll_feedback_flags.aconfig
+++ b/core/java/android/view/flags/scroll_feedback_flags.aconfig
@@ -5,4 +5,11 @@ flag {
name: "scroll_feedback_api"
description: "Enable the scroll feedback APIs"
bug: "239594271"
+}
+
+flag {
+ namespace: "toolkit"
+ name: "use_view_based_rotary_encoder_scroll_haptics"
+ description: "If enabled, the rotary encoder scroll haptic implementation in the View class will be used, and the HapticScrollFeedbackProvider logic for rotary encoder haptic will be muted."
+ bug: "299587011"
} \ No newline at end of file
diff --git a/core/res/res/values/config.xml b/core/res/res/values/config.xml
index 2e2ec5ba52b3..3d0af3dbde3d 100644
--- a/core/res/res/values/config.xml
+++ b/core/res/res/values/config.xml
@@ -6711,4 +6711,7 @@
{@link MotionEvent#AXIS_SCROLL} generated by {@link InputDevice#SOURCE_ROTARY_ENCODER}
devices. -->
<bool name="config_viewRotaryEncoderHapticScrollFedbackEnabled">false</bool>
+ <!-- Whether the View-based scroll haptic feedback implementation is enabled for
+ {@link InputDevice#SOURCE_ROTARY_ENCODER}s. -->
+ <bool name="config_viewBasedRotaryEncoderHapticsEnabled">false</bool>
</resources>
diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml
index fe1144958c52..e0f128d5d416 100644
--- a/core/res/res/values/symbols.xml
+++ b/core/res/res/values/symbols.xml
@@ -5235,4 +5235,5 @@
<java-symbol type="array" name="config_tvExternalInputLoggingDeviceOnScreenDisplayNames" />
<java-symbol type="array" name="config_tvExternalInputLoggingDeviceBrandNames" />
<java-symbol type="bool" name="config_viewRotaryEncoderHapticScrollFedbackEnabled" />
+ <java-symbol type="bool" name="config_viewBasedRotaryEncoderHapticsEnabled" />
</resources>
diff --git a/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java b/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
index d2af2a734330..3dfeb7f0fc05 100644
--- a/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
+++ b/core/tests/coretests/src/android/view/HapticScrollFeedbackProviderTest.java
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
import android.content.Context;
import android.platform.test.annotations.Presubmit;
+import android.view.flags.FeatureFlags;
import androidx.test.InstrumentationRegistry;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@@ -49,6 +50,7 @@ public final class HapticScrollFeedbackProviderTest {
private TestView mView;
@Mock ViewConfiguration mMockViewConfig;
+ @Mock FeatureFlags mMockFeatureFlags;
private HapticScrollFeedbackProvider mProvider;
@@ -56,9 +58,52 @@ public final class HapticScrollFeedbackProviderTest {
public void setUp() {
mMockViewConfig = mock(ViewConfiguration.class);
setHapticScrollFeedbackEnabled(true);
+ when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
+ .thenReturn(false);
mView = new TestView(InstrumentationRegistry.getContext());
- mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig);
+ mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
+ /* disabledIfViewPlaysScrollHaptics= */ true);
+ }
+
+ @Test
+ public void testRotaryEncoder_noFeedbackWhenViewBasedFeedbackIsEnabled() {
+ when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
+ .thenReturn(true);
+ setHapticScrollTickInterval(5);
+
+ mProvider.onScrollProgress(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
+ /* deltaInPixels= */ 10);
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+ mProvider.onScrollLimit(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
+ /* isStart= */ true);
+
+ assertNoFeedback(mView);
+ }
+
+ @Test
+ public void testRotaryEncoder_feedbackWhenDisregardingViewBasedScrollHaptics() {
+ mProvider = new HapticScrollFeedbackProvider(mView, mMockViewConfig,
+ /* disabledIfViewPlaysScrollHaptics= */ false);
+ when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
+ .thenReturn(true);
+ setHapticScrollTickInterval(5);
+
+ mProvider.onScrollProgress(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
+ /* deltaInPixels= */ 10);
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+ mProvider.onScrollLimit(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
+ /* isStart= */ true);
+
+ assertFeedbackCount(mView, SCROLL_TICK, 1);
+ assertFeedbackCount(mView, SCROLL_ITEM_FOCUS, 1);
+ assertFeedbackCount(mView, SCROLL_LIMIT, 1);
}
@Test
@@ -94,20 +139,26 @@ public final class HapticScrollFeedbackProviderTest {
@Test
public void testScrollLimit_start() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ true);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_stop() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
@@ -207,8 +258,6 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* deltaInPixels= */ 60);
-
-
assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_TICK, 2);
}
@@ -225,6 +274,9 @@ public final class HapticScrollFeedbackProviderTest {
@Test
public void testScrollLimit_startAndEndLimit_playsOnlyOneFeedback() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -232,11 +284,14 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ true);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_doubleStartLimit_playsOnlyOneFeedback() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ true);
@@ -244,11 +299,14 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ true);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_doubleEndLimit_playsOnlyOneFeedback() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
+
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -256,11 +314,13 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_notEnabledWithZeroProgress() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -275,11 +335,13 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_enabledWithProgress() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -291,11 +353,13 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT, 2);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 2);
}
@Test
public void testScrollLimit_enabledWithSnap() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -310,7 +374,9 @@ public final class HapticScrollFeedbackProviderTest {
}
@Test
- public void testScrollLimit_enabledWithDissimilarSnap() {
+ public void testScrollLimit_notEnabledWithDissimilarSnap() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -321,11 +387,13 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 2);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
@Test
public void testScrollLimit_enabledWithDissimilarProgress() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
@@ -337,28 +405,27 @@ public final class HapticScrollFeedbackProviderTest {
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT, 2);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 2);
}
@Test
- public void testScrollLimit_enabledWithMotionFromDifferentDeviceId() {
+ public void testScrollLimit_doesNotEnabledWithMotionFromDifferentDeviceId() {
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- mProvider.onScrollLimit(
- INPUT_DEVICE_2,
- InputDevice.SOURCE_ROTARY_ENCODER,
- MotionEvent.AXIS_SCROLL,
- /* isStart= */ false);
+ mProvider.onSnapToItem(
+ INPUT_DEVICE_2, InputDevice.SOURCE_ROTARY_ENCODER, MotionEvent.AXIS_SCROLL);
mProvider.onScrollLimit(
INPUT_DEVICE_1,
InputDevice.SOURCE_ROTARY_ENCODER,
MotionEvent.AXIS_SCROLL,
/* isStart= */ false);
- assertOnlyFeedback(mView, HapticFeedbackConstants.SCROLL_LIMIT, 3);
+ assertFeedbackCount(mView, HapticFeedbackConstants.SCROLL_LIMIT, 1);
}
diff --git a/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java b/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java
new file mode 100644
index 000000000000..9a5c1c5112e6
--- /dev/null
+++ b/core/tests/coretests/src/android/view/RotaryScrollHapticsTest.java
@@ -0,0 +1,259 @@
+/*
+ * Copyright (C) 2023 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 android.view;
+
+import static android.view.InputDevice.SOURCE_CLASS_POINTER;
+import static android.view.InputDevice.SOURCE_ROTARY_ENCODER;
+import static android.view.MotionEvent.ACTION_SCROLL;
+import static android.view.MotionEvent.AXIS_HSCROLL;
+import static android.view.MotionEvent.AXIS_SCROLL;
+import static android.view.MotionEvent.AXIS_VSCROLL;
+
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+
+/** Test for the rotary scroll haptics implementation in the View class. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+@Presubmit
+public final class RotaryScrollHapticsTest {
+ private static final int TEST_ROTARY_DEVICE_ID = 1;
+ private static final int TEST_RANDOM_DEVICE_ID = 2;
+
+ private static final float TEST_SCALED_VERTICAL_SCROLL_FACTOR = 5f;
+
+ @Mock ViewConfiguration mMockViewConfig;
+ @Mock HapticScrollFeedbackProvider mMockScrollFeedbackProvider;
+
+ private TestGenericMotionEventControllingView mView;
+
+ @Before
+ public void setUp() {
+ mMockViewConfig = mock(ViewConfiguration.class);
+ mMockScrollFeedbackProvider = mock(HapticScrollFeedbackProvider.class);
+
+ Context context = InstrumentationRegistry.getTargetContext();
+ mView = new TestGenericMotionEventControllingView(context);
+ mView.mScrollFeedbackProvider = mMockScrollFeedbackProvider;
+
+ ViewConfiguration.setInstanceForTesting(context, mMockViewConfig);
+ when(mMockViewConfig.getScaledVerticalScrollFactor())
+ .thenReturn(TEST_SCALED_VERTICAL_SCROLL_FACTOR);
+ mockRotaryScrollHapticsEnabled(true);
+ }
+
+ @After
+ public void tearDown() {
+ ViewConfiguration.resetCacheForTesting();
+ }
+
+ @Test
+ public void testRotaryScrollHapticsDisabled_producesNoHapticEvent() {
+ mockRotaryScrollHapticsEnabled(false);
+
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ false);
+ mView.dispatchGenericMotionEvent(createRotaryEvent(-20));
+
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ true);
+ mView.dispatchGenericMotionEvent(createRotaryEvent(20));
+
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ true);
+ mView.dispatchGenericMotionEvent(createRotaryEvent(10));
+
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ false);
+ mView.dispatchGenericMotionEvent(createRotaryEvent(-10));
+
+ verifyNoScrollLimit();
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testNonRotaryEncoderMotion_producesNoHapticEvent() {
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ false);
+ mView.dispatchGenericMotionEvent(createGenericPointerEvent(1, 2));
+
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ true);
+ mView.dispatchGenericMotionEvent(createGenericPointerEvent(2, 2));
+
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ true);
+ mView.dispatchGenericMotionEvent(createGenericPointerEvent(1, 3));
+
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ false);
+ mView.dispatchGenericMotionEvent(createGenericPointerEvent(-1, -2));
+
+ verifyNoScrollLimit();
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testScrollLimit_start_genericMotionEventCallbackReturningFalse_doesScrollLimit() {
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ false);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(20));
+
+ verifyScrollLimit(/* isStart= */ true);
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testScrollLimit_start_genericMotionEventCallbackReturningTrue_doesScrollLimit() {
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ false);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(20));
+
+ verifyScrollLimit(/* isStart= */ true);
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testScrollLimit_end_genericMotionEventCallbackReturningFalse_doesScrollLimit() {
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ false);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(-20));
+
+ verifyScrollLimit(/* isStart= */ false);
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testScrollLimit_end_genericMotionEventCallbackReturningTrue_doesScrollLimit() {
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ false);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(-20));
+
+ verifyScrollLimit(/* isStart= */ false);
+ verifyNoScrollProgress();
+ }
+
+ @Test
+ public void testScrollProgress_genericMotionEventCallbackReturningFalse_doesScrollProgress() {
+ mView.configureGenericMotion(/* result= */ false, /* scroll= */ true);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(20));
+
+ verifyScrollProgress(-1 * 20 * (int) TEST_SCALED_VERTICAL_SCROLL_FACTOR);
+ verifyNoScrollLimit();
+ }
+
+ @Test
+ public void testScrollProgress_genericMotionEventCallbackReturningTrue_doesScrollProgress() {
+ mView.configureGenericMotion(/* result= */ true, /* scroll= */ true);
+
+ mView.dispatchGenericMotionEvent(createRotaryEvent(-20));
+
+ verifyScrollProgress(-1 * -20 * (int) TEST_SCALED_VERTICAL_SCROLL_FACTOR);
+ verifyNoScrollLimit();
+ }
+
+ private void verifyScrollProgress(int scrollPixels) {
+ verify(mMockScrollFeedbackProvider).onScrollProgress(
+ TEST_ROTARY_DEVICE_ID, SOURCE_ROTARY_ENCODER, AXIS_SCROLL, scrollPixels);
+ }
+
+ private void verifyNoScrollProgress() {
+ verify(mMockScrollFeedbackProvider, never()).onScrollProgress(
+ anyInt(), anyInt(), anyInt(), anyInt());
+ }
+
+ private void verifyScrollLimit(boolean isStart) {
+ verify(mMockScrollFeedbackProvider).onScrollLimit(
+ TEST_ROTARY_DEVICE_ID, SOURCE_ROTARY_ENCODER, AXIS_SCROLL, isStart);
+ }
+
+ private void verifyNoScrollLimit() {
+ verify(mMockScrollFeedbackProvider, never()).onScrollLimit(
+ anyInt(), anyInt(), anyInt(), anyBoolean());
+ }
+
+ private void mockRotaryScrollHapticsEnabled(boolean enabled) {
+ when(mMockViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled())
+ .thenReturn(enabled);
+ }
+
+ /**
+ * Test implementation for View giving control on behavior of
+ * {@link View#onGenericMotionEvent(MotionEvent)}.
+ */
+ private static final class TestGenericMotionEventControllingView extends View {
+ private boolean mGenericMotionResult;
+ private boolean mScrollOnGenericMotion;
+
+ TestGenericMotionEventControllingView(Context context) {
+ super(context);
+ }
+
+ void configureGenericMotion(boolean result, boolean scroll) {
+ mGenericMotionResult = result;
+ mScrollOnGenericMotion = scroll;
+ }
+
+ @Override
+ public boolean onGenericMotionEvent(MotionEvent event) {
+ if (mScrollOnGenericMotion) {
+ scrollTo(100, 200); // scroll values random (not relevant for tests).
+ }
+ return mGenericMotionResult;
+ }
+ }
+
+ private static MotionEvent createRotaryEvent(float scroll) {
+ MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.setAxisValue(AXIS_SCROLL, scroll);
+
+ return createGenericMotionEvent(
+ TEST_ROTARY_DEVICE_ID, SOURCE_ROTARY_ENCODER, ACTION_SCROLL, coords);
+ }
+
+ private static MotionEvent createGenericPointerEvent(float hScroll, float vScroll) {
+ MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords();
+ coords.setAxisValue(AXIS_HSCROLL, hScroll);
+ coords.setAxisValue(AXIS_VSCROLL, vScroll);
+
+ return createGenericMotionEvent(
+ TEST_RANDOM_DEVICE_ID, SOURCE_CLASS_POINTER, ACTION_SCROLL, coords);
+ }
+
+ private static MotionEvent createGenericMotionEvent(
+ int deviceId, int source, int action, MotionEvent.PointerCoords coords) {
+ MotionEvent.PointerProperties props = new MotionEvent.PointerProperties();
+ props.id = 0;
+
+ return MotionEvent.obtain(
+ /* downTime= */ 0, /* eventTime= */ 100, action, /* pointerCount= */ 1,
+ new MotionEvent.PointerProperties[] {props},
+ new MotionEvent.PointerCoords[] {coords},
+ /* metaState= */ 0, /* buttonState= */ 0, /* xPrecision= */ 0, /* yPrecision= */ 0,
+ deviceId, /* edgeFlags= */ 0, source, /* flags= */ 0);
+ }
+}