diff options
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); + } +} |