Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit bf0dd5d7 authored by Yeabkal Wubshit's avatar Yeabkal Wubshit
Browse files

Dynamically disable View-based rotary haptics

Before this change, View-based rotary haptics was configured for the
entire device, controlled by an XML config. As long as this config is
enabled, the ScrollFeedbackProvider's (SFP) rotary feedback was also
disabled (to avoid duplicate rotary haptics).

With this change, the decision to enable/disable the View-based rotary
haptics will be View-specific. If we detect that a View implementation
is directly using the SFP API, we will favor the SFP API and disable the
View-based rotary haptics. This allows us to continue providing rotary
haptics for View implementations without SFP integration, while cleanly
disabling rotary View-based rotary haptics in cases where SFP has been
integrated to the specific View.

Bug: 377998870
Test: manually on a widget extending View, with SFP integration
Test: manually on a widget extending View, with no SFP integration
Test: manually on a widget NOT extending View, with SFP integration
Test: atest RotaryScrollHapticsTest HapticScrollFeedbackProviderTest
Flag: android.view.flags.dynamic_view_rotary_haptics_configuration

Change-Id: Iac63b5f7046f216f8ec958245021da5fa0a40fa7
parent 616d04cc
Loading
Loading
Loading
Loading
+16 −12
Original line number Diff line number Diff line
@@ -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 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {

    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 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {
    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 @@ public class HapticScrollFeedbackProvider implements ScrollFeedbackProvider {
            mAxis = axis;
            mDeviceId = deviceId;

            if (mDisabledIfViewPlaysScrollHaptics
            if (!dynamicViewRotaryHapticsConfiguration()
                    && !mIsFromView
                    && (source == InputDevice.SOURCE_ROTARY_ENCODER)
                    && mViewConfig.isViewBasedRotaryEncoderHapticScrollFeedbackEnabled()) {
                mHapticScrollFeedbackEnabled = false;
+21 −5
Original line number Diff line number Diff line
@@ -16752,9 +16752,7 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
                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;
        }
@@ -16771,7 +16769,10 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
        // 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 {
@@ -18714,7 +18715,7 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
    private HapticScrollFeedbackProvider getScrollFeedbackProvider() {
        if (mScrollFeedbackProvider == null) {
            mScrollFeedbackProvider = new HapticScrollFeedbackProvider(this,
                    ViewConfiguration.get(mContext), /* disabledIfViewPlaysScrollHaptics= */ false);
                    ViewConfiguration.get(mContext), /* isFromView= */ true);
        }
        return mScrollFeedbackProvider;
    }
@@ -18743,6 +18744,21 @@ public class View implements Drawable.Callback, KeyEvent.Callback,
        }
    }
    /**
     * 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
+7 −0
Original line number Diff line number Diff line
@@ -23,3 +23,10 @@ flag {
    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 "
}
+25 −5
Original line number Diff line number Diff line
@@ -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 @@ public final class HapticScrollFeedbackProviderTest {

        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);
@@ -96,8 +98,25 @@ public final class HapticScrollFeedbackProviderTest {
        assertNoFeedback(mView);
    }

    @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 @@ public final class HapticScrollFeedbackProviderTest {
    @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 @@ public final class HapticScrollFeedbackProviderTest {
        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 @@ public final class HapticScrollFeedbackProviderTest {

    @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(
+43 −0
Original line number Diff line number Diff line
@@ -30,8 +30,12 @@ import static org.mockito.Mockito.when;
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 androidx.test.filters.SmallTest;

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 @@ import org.mockito.Mock;
@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;

@@ -166,6 +173,26 @@ public final class RotaryScrollHapticsTest {
        verifyNoScrollLimit();
    }

    @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 @@ public final class RotaryScrollHapticsTest {
    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 final class RotaryScrollHapticsTest {
        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;
        }