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

Commit d544c646 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing the Slider State Listener components for sliders in SysUI.

The interface for a slider listener as well as its implementation are
introduced. The implementation represents the haptic provider that plays
haptics on slider events.

Test: atest SliderHapticFeedbackProviderTest
Bug: 295932558
(cherry picked from https://googleplex-android-review.googlesource.com/q/commit:13ee3310de4da0901648dfa801e9822c0c886e24)

Change-Id: Ie0b6ae9a262a7a79e9d92ae2a420cf12cf44af7a
parent 6ebed9bf
Loading
Loading
Loading
Loading
+43 −0
Original line number Diff line number Diff line
/*
 * 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 com.android.systemui.haptics.slider

import androidx.annotation.FloatRange

/** Configuration parameters of a [SliderHapticFeedbackProvider] */
data class SliderHapticFeedbackConfig(
    /** Interpolator factor for velocity-based vibration scale interpolations. Must be positive */
    val velocityInterpolatorFactor: Float = 1f,
    /** Interpolator factor for progress-based vibration scale interpolations. Must be positive */
    val progressInterpolatorFactor: Float = 1f,
    /** Minimum vibration scale for vibrations based on slider progress */
    @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMinScale: Float = 0f,
    /** Maximum vibration scale for vibrations based on slider progress */
    @FloatRange(from = 0.0, to = 1.0) val progressBasedDragMaxScale: Float = 0.2f,
    /** Additional vibration scaling due to velocity */
    @FloatRange(from = 0.0, to = 1.0) val additionalVelocityMaxBump: Float = 0.15f,
    /** Additional time delta to wait between drag texture vibrations */
    @FloatRange(from = 0.0) val deltaMillisForDragInterval: Float = 0f,
    /** Number of low ticks in a drag texture composition. This is not expected to change */
    val numberOfLowTicks: Int = 5,
    /** Maximum velocity allowed for vibration scaling. This is not expected to change. */
    val maxVelocityToScale: Float = 2000f, /* In pixels/sec */
    /** Vibration scale at the upper bookend of the slider */
    @FloatRange(from = 0.0, to = 1.0) val upperBookendScale: Float = 1f,
    /** Vibration scale at the lower bookend of the slider */
    @FloatRange(from = 0.0, to = 1.0) val lowerBookendScale: Float = 0.05f,
)
+160 −0
Original line number Diff line number Diff line
/*
 * 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 com.android.systemui.haptics.slider

import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.view.VelocityTracker
import android.view.animation.AccelerateInterpolator
import androidx.annotation.FloatRange
import com.android.systemui.statusbar.VibratorHelper
import kotlin.math.abs
import kotlin.math.min

/**
 * Listener of slider events that triggers haptic feedback.
 *
 * @property[vibratorHelper] Singleton instance of the [VibratorHelper] to deliver haptics.
 * @property[velocityTracker] Instance of a [VelocityTracker] that tracks slider dragging velocity.
 * @property[config] Configuration parameters for vibration encapsulated as a
 *   [SliderHapticFeedbackConfig].
 * @property[clock] Clock to obtain elapsed real time values.
 */
class SliderHapticFeedbackProvider(
    private val vibratorHelper: VibratorHelper,
    private val velocityTracker: VelocityTracker,
    private val config: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
    private val clock: com.android.systemui.util.time.SystemClock,
) : SliderStateListener {

    private val velocityAccelerateInterpolator =
        AccelerateInterpolator(config.velocityInterpolatorFactor)
    private val positionAccelerateInterpolator =
        AccelerateInterpolator(config.progressInterpolatorFactor)
    private var dragTextureLastTime = clock.elapsedRealtime()
    private val lowTickDurationMs =
        vibratorHelper.getPrimitiveDurations(VibrationEffect.Composition.PRIMITIVE_LOW_TICK)[0]
    private var hasVibratedAtLowerBookend = false
    private var hasVibratedAtUpperBookend = false

    /** Time threshold to wait before making new API call. */
    private val thresholdUntilNextDragCallMillis =
        lowTickDurationMs * config.numberOfLowTicks + config.deltaMillisForDragInterval

    /**
     * Vibrate when the handle reaches either bookend with a certain velocity.
     *
     * @param[absoluteVelocity] Velocity of the handle when it reached the bookend.
     */
    private fun vibrateOnEdgeCollision(absoluteVelocity: Float) {
        val velocityInterpolated =
            velocityAccelerateInterpolator.getInterpolation(
                min(absoluteVelocity / config.maxVelocityToScale, 1f)
            )
        val bookendScaleRange = config.upperBookendScale - config.lowerBookendScale
        val bookendsHitScale = bookendScaleRange * velocityInterpolated + config.lowerBookendScale

        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK, bookendsHitScale)
                .compose()
        vibratorHelper.vibrate(vibration, VIBRATION_ATTRIBUTES_PIPELINING)
    }

    /**
     * Create a drag texture vibration based on velocity and slider progress.
     *
     * @param[absoluteVelocity] Absolute velocity of the handle.
     * @param[normalizedSliderProgress] Progress of the slider handled normalized to the range from
     *   0F to 1F (inclusive).
     */
    private fun vibrateDragTexture(
        absoluteVelocity: Float,
        @FloatRange(from = 0.0, to = 1.0) normalizedSliderProgress: Float
    ) {
        // Check if its time to vibrate
        val currentTime = clock.elapsedRealtime()
        val elapsedSinceLastDrag = currentTime - dragTextureLastTime
        if (elapsedSinceLastDrag < thresholdUntilNextDragCallMillis) return

        val velocityInterpolated =
            velocityAccelerateInterpolator.getInterpolation(
                min(absoluteVelocity / config.maxVelocityToScale, 1f)
            )

        // Scaling of vibration due to the position of the slider
        val positionScaleRange = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
        val sliderProgressInterpolated =
            positionAccelerateInterpolator.getInterpolation(normalizedSliderProgress)
        val positionBasedScale =
            positionScaleRange * sliderProgressInterpolated + config.progressBasedDragMinScale

        // Scaling bump due to velocity
        val velocityBasedScale = velocityInterpolated * config.additionalVelocityMaxBump

        // Total scale
        val scale = positionBasedScale + velocityBasedScale

        // Trigger the vibration composition
        val composition = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            composition.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, scale)
        }
        vibratorHelper.vibrate(composition.compose(), VIBRATION_ATTRIBUTES_PIPELINING)
        dragTextureLastTime = currentTime
    }

    override fun onHandleAcquiredByTouch() {}

    override fun onHandleReleasedFromTouch() {}

    override fun onLowerBookend() {
        if (!hasVibratedAtLowerBookend) {
            velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
            vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
            hasVibratedAtLowerBookend = true
        }
    }

    override fun onUpperBookend() {
        if (!hasVibratedAtUpperBookend) {
            velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
            vibrateOnEdgeCollision(abs(velocityTracker.xVelocity))
            hasVibratedAtUpperBookend = true
        }
    }

    override fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float) {
        velocityTracker.computeCurrentVelocity(UNITS_SECOND, config.maxVelocityToScale)
        vibrateDragTexture(abs(velocityTracker.xVelocity), progress)
        hasVibratedAtUpperBookend = false
        hasVibratedAtLowerBookend = false
    }

    override fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}

    override fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float) {}

    private companion object {
        private val VIBRATION_ATTRIBUTES_PIPELINING =
            VibrationAttributes.Builder()
                .setUsage(VibrationAttributes.USAGE_TOUCH)
                .setFlags(VibrationAttributes.FLAG_PIPELINED_EFFECT)
                .build()
        private const val UNITS_SECOND = 1000
    }
}
+64 −0
Original line number Diff line number Diff line
/*
 * 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 com.android.systemui.haptics.slider

import androidx.annotation.FloatRange

/** Listener of events from a slider (such as [android.widget.SeekBar]) */
interface SliderStateListener {

    /** Notification that the handle is acquired by touch */
    fun onHandleAcquiredByTouch()

    /** Notification that the handle was released from touch */
    fun onHandleReleasedFromTouch()

    /** Notification that the handle reached the lower bookend */
    fun onLowerBookend()

    /** Notification that the handle reached the upper bookend */
    fun onUpperBookend()

    /**
     * Notification that the slider reached a certain progress on the slider track.
     *
     * This method is called in all intermediate steps of a continuous progress change as the slider
     * moves through the slider track.
     *
     * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive).
     */
    fun onProgress(@FloatRange(from = 0.0, to = 1.0) progress: Float)

    /**
     * Notification that the slider handle jumped to a selected progress on the slider track.
     *
     * This method is specific to the case when the handle performed a single jump to a position on
     * the slider track and reached the corresponding progress. In this case, [onProgress] is not
     * called and the new progress reached is represented by the [progress] parameter.
     *
     * @param[progress] The selected progress on the slider track that the handle jumps to. The
     *   progress is in the range from 0F to 1F (inclusive).
     */
    fun onProgressJump(@FloatRange(from = 0.0, to = 1.0) progress: Float)

    /**
     * Notification that the slider handle was moved by a button press.
     *
     * @param[progress] The progress of the slider in the range from 0F to 1F (inclusive).
     */
    fun onSelectAndArrow(@FloatRange(from = 0.0, to = 1.0) progress: Float)
}
+21 −0
Original line number Diff line number Diff line
@@ -117,6 +117,16 @@ public class VibratorHelper {
        mExecutor.execute(() -> mVibrator.vibrate(effect));
    }

    /**
     * @see Vibrator#vibrate(VibrationEffect, VibrationAttributes)
     */
    public void vibrate(@NonNull VibrationEffect effect, @NonNull VibrationAttributes attributes) {
        if (!hasVibrator()) {
            return;
        }
        mExecutor.execute(() -> mVibrator.vibrate(effect, attributes));
    }

    /**
     * @see Vibrator#hasVibrator()
     */
@@ -153,6 +163,17 @@ public class VibratorHelper {
                HARDWARE_FEEDBACK_VIBRATION_ATTRIBUTES);
    }

    /**
     * @see Vibrator#getPrimitiveDurations(int...)
     */
    public int[] getPrimitiveDurations(int... primitiveIds) {
        if (!hasVibrator()) {
            return new int[]{0};
        } else {
            return mVibrator.getPrimitiveDurations(primitiveIds);
        }
    }

    /**
     * Perform a vibration using a view and the one-way API with flags
     * @see View#performHapticFeedback(int feedbackConstant, int flags)
+247 −0
Original line number Diff line number Diff line
/*
 * 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 com.android.systemui.haptics.slider

import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.view.VelocityTracker
import android.view.animation.AccelerateInterpolator
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.eq
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.time.FakeSystemClock
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations

@SmallTest
@RunWith(AndroidJUnit4::class)
class SliderHapticFeedbackProviderTest : SysuiTestCase() {

    @Mock private lateinit var velocityTracker: VelocityTracker
    @Mock private lateinit var vibratorHelper: VibratorHelper

    private val config = SliderHapticFeedbackConfig()
    private val clock = FakeSystemClock()

    private val lowTickDuration = 12 // Mocked duration of a low tick
    private val dragTextureThresholdMillis =
        lowTickDuration * config.numberOfLowTicks + config.deltaMillisForDragInterval
    private val progressInterpolator = AccelerateInterpolator(config.progressInterpolatorFactor)
    private val velocityInterpolator = AccelerateInterpolator(config.velocityInterpolatorFactor)
    private lateinit var sliderHapticFeedbackProvider: SliderHapticFeedbackProvider

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        whenever(vibratorHelper.getPrimitiveDurations(any()))
            .thenReturn(intArrayOf(lowTickDuration))
        whenever(velocityTracker.xVelocity).thenReturn(config.maxVelocityToScale)
        sliderHapticFeedbackProvider =
            SliderHapticFeedbackProvider(vibratorHelper, velocityTracker, config, clock)
    }

    @Test
    fun playHapticAtLowerBookend_playsClick() {
        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        sliderHapticFeedbackProvider.onLowerBookend()

        verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtLowerBookend_twoTimes_playsClickOnlyOnce() {
        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        sliderHapticFeedbackProvider.onLowerBookend()
        sliderHapticFeedbackProvider.onLowerBookend()

        verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtUpperBookend_playsClick() {
        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        sliderHapticFeedbackProvider.onUpperBookend()

        verify(vibratorHelper).vibrate(eq(vibration), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtUpperBookend_twoTimes_playsClickOnlyOnce() {
        val vibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        sliderHapticFeedbackProvider.onUpperBookend()
        sliderHapticFeedbackProvider.onUpperBookend()

        verify(vibratorHelper, times(1))
            .vibrate(eq(vibration), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtProgress_onQuickSuccession_playsLowTicksOnce() {
        // GIVEN max velocity and slider progress
        val progress = 1f
        val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
        val ticks = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
        }

        // GIVEN system running for 1s
        clock.advanceTime(1000)

        // WHEN two calls to play occur immediately
        sliderHapticFeedbackProvider.onProgress(progress)
        sliderHapticFeedbackProvider.onProgress(progress)

        // THEN the correct composition only plays once
        verify(vibratorHelper, times(1))
            .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtProgress_afterNextDragThreshold_playsLowTicksTwice() {
        // GIVEN max velocity and slider progress
        val progress = 1f
        val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
        val ticks = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
        }

        // GIVEN system running for 1s
        clock.advanceTime(1000)

        // WHEN two calls to play occur with the required threshold separation
        sliderHapticFeedbackProvider.onProgress(progress)
        clock.advanceTime(dragTextureThresholdMillis.toLong())
        sliderHapticFeedbackProvider.onProgress(progress)

        // THEN the correct composition plays two times
        verify(vibratorHelper, times(2))
            .vibrate(eq(ticks.compose()), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtLowerBookend_afterPlayingAtProgress_playsTwice() {
        // GIVEN max velocity and slider progress
        val progress = 1f
        val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
        val ticks = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
        }
        val bookendVibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        // GIVEN a vibration at the lower bookend followed by a request to vibrate at progress
        sliderHapticFeedbackProvider.onLowerBookend()
        sliderHapticFeedbackProvider.onProgress(progress)

        // WHEN a vibration is to trigger again at the lower bookend
        sliderHapticFeedbackProvider.onLowerBookend()

        // THEN there are two bookend vibrations
        verify(vibratorHelper, times(2))
            .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java))
    }

    @Test
    fun playHapticAtUpperBookend_afterPlayingAtProgress_playsTwice() {
        // GIVEN max velocity and slider progress
        val progress = 1f
        val expectedScale = scaleAtProgressChange(config.maxVelocityToScale.toFloat(), progress)
        val ticks = VibrationEffect.startComposition()
        repeat(config.numberOfLowTicks) {
            ticks.addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, expectedScale)
        }
        val bookendVibration =
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_CLICK,
                    scaleAtBookends(config.maxVelocityToScale)
                )
                .compose()

        // GIVEN a vibration at the upper bookend followed by a request to vibrate at progress
        sliderHapticFeedbackProvider.onUpperBookend()
        sliderHapticFeedbackProvider.onProgress(progress)

        // WHEN a vibration is to trigger again at the upper bookend
        sliderHapticFeedbackProvider.onUpperBookend()

        // THEN there are two bookend vibrations
        verify(vibratorHelper, times(2))
            .vibrate(eq(bookendVibration), any(VibrationAttributes::class.java))
    }

    private fun scaleAtBookends(velocity: Float): Float {
        val range = config.upperBookendScale - config.lowerBookendScale
        val interpolatedVelocity =
            velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale)
        return interpolatedVelocity * range + config.lowerBookendScale
    }

    private fun scaleAtProgressChange(velocity: Float, progress: Float): Float {
        val range = config.progressBasedDragMaxScale - config.progressBasedDragMinScale
        val interpolatedVelocity =
            velocityInterpolator.getInterpolation(velocity / config.maxVelocityToScale)
        val interpolatedProgress = progressInterpolator.getInterpolation(progress)
        val bump = interpolatedVelocity * config.additionalVelocityMaxBump
        return interpolatedProgress * range + config.progressBasedDragMinScale + bump
    }
}
Loading