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

Commit 75647a90 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez
Browse files

Introducing VibratorHelper Fixture in Kosmos

A FakeVibratorHelper is added as a Fixture in Kosmos for better
testability of haptic features that depend on the VibratorHelper. The
dependency avoids hardware interactions (with any Vibrator) and only
keeps track of a history of vibrations "delivered". The list of
supported haptic primitives is also customizable.

Test: atest SystemUITests:SliderHapticFeedbackProviderTest
Flag: NONE
Bug: TBD

Change-Id: I4c8ac023a2f8a1a086ea114bf3da3fb82ae25dfb
parent 6a4f057c
Loading
Loading
Loading
Loading
+237 −212
Original line number Diff line number Diff line
@@ -16,25 +16,22 @@

package com.android.systemui.haptics.slider

import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.view.VelocityTracker
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.haptics.vibratorHelper
import com.android.systemui.testKosmos
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.time.FakeSystemClock
import com.android.systemui.util.time.fakeSystemClock
import kotlin.math.max
import kotlin.test.assertEquals
import kotlin.test.assertTrue
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
@@ -42,10 +39,10 @@ import org.mockito.MockitoAnnotations
class SliderHapticFeedbackProviderTest : SysuiTestCase() {

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

    private val kosmos = testKosmos()

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

    private val lowTickDuration = 12 // Mocked duration of a low tick
    private val dragTextureThresholdMillis =
@@ -55,32 +52,42 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        whenever(vibratorHelper.getPrimitiveDurations(any()))
            .thenReturn(intArrayOf(lowTickDuration))
        whenever(velocityTracker.isAxisSupported(config.velocityAxis)).thenReturn(true)
        whenever(velocityTracker.getAxisVelocity(config.velocityAxis))
            .thenReturn(config.maxVelocityToScale)

        kosmos.vibratorHelper.primitiveDurations[VibrationEffect.Composition.PRIMITIVE_LOW_TICK] =
            lowTickDuration
        sliderHapticFeedbackProvider =
            SliderHapticFeedbackProvider(vibratorHelper, velocityTracker, config, clock)
            SliderHapticFeedbackProvider(
                kosmos.vibratorHelper,
                velocityTracker,
                config,
                kosmos.fakeSystemClock,
            )
    }

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

            sliderHapticFeedbackProvider.onLowerBookend()

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

    @Test
    fun playHapticAtLowerBookend_twoTimes_playsClickOnlyOnce() {
    fun playHapticAtLowerBookend_twoTimes_playsClickOnlyOnce() =
        with(kosmos) {
            val vibration =
                VibrationEffect.startComposition()
                    .addPrimitive(
@@ -92,43 +99,49 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
            sliderHapticFeedbackProvider.onLowerBookend()
            sliderHapticFeedbackProvider.onLowerBookend()

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

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

            sliderHapticFeedbackProvider.onUpperBookend()

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

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

            sliderHapticFeedbackProvider.onUpperBookend()
            sliderHapticFeedbackProvider.onUpperBookend()

        verify(vibratorHelper, times(1))
            .vibrate(eq(vibration), any(VibrationAttributes::class.java))
            assertEquals(/* expected=*/ 1, vibratorHelper.timesVibratedWithEffect(vibration))
        }

    @Test
    fun playHapticAtProgress_onQuickSuccession_playsLowTicksOnce() {
    fun playHapticAtProgress_onQuickSuccession_playsLowTicksOnce() =
        with(kosmos) {
            // GIVEN max velocity and slider progress
            val progress = 1f
            val expectedScale =
@@ -142,43 +155,44 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
            }

            // GIVEN system running for 1s
        clock.advanceTime(1000)
            fakeSystemClock.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))
            assertEquals(/* expected=*/ 1, vibratorHelper.timesVibratedWithEffect(ticks.compose()))
        }

    @Test
    fun playHapticAtProgress_beforeNextDragThreshold_playsLowTicksOnce() {
    fun playHapticAtProgress_beforeNextDragThreshold_playsLowTicksOnce() =
        with(kosmos) {
            // GIVEN max velocity and a slider progress at half progress
            val firstProgress = 0.5f
            val firstTicks = generateTicksComposition(config.maxVelocityToScale, firstProgress)

            // Given a second slider progress event smaller than the progress threshold
        val secondProgress = firstProgress + max(0f, config.deltaProgressForDragThreshold - 0.01f)
            val secondProgress =
                firstProgress + max(0f, config.deltaProgressForDragThreshold - 0.01f)

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

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

            // THEN Only the first compositions plays
        verify(vibratorHelper, times(1))
            .vibrate(eq(firstTicks), any(VibrationAttributes::class.java))
        verify(vibratorHelper, times(1))
            .vibrate(any(VibrationEffect::class.java), any(VibrationAttributes::class.java))
            assertEquals(/* expected= */ 1, vibratorHelper.timesVibratedWithEffect(firstTicks))
            assertEquals(/* expected= */ 1, vibratorHelper.totalVibrations)
        }

    @Test
    fun playHapticAtProgress_afterNextDragThreshold_playsLowTicksTwice() {
    fun playHapticAtProgress_afterNextDragThreshold_playsLowTicksTwice() =
        with(kosmos) {
            // GIVEN max velocity and a slider progress at half progress
            val firstProgress = 0.5f
            val firstTicks = generateTicksComposition(config.maxVelocityToScale, firstProgress)
@@ -188,22 +202,22 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
            val secondTicks = generateTicksComposition(config.maxVelocityToScale, secondProgress)

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

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

            // THEN the correct compositions play
        verify(vibratorHelper, times(1))
            .vibrate(eq(firstTicks), any(VibrationAttributes::class.java))
        verify(vibratorHelper, times(1))
            .vibrate(eq(secondTicks), any(VibrationAttributes::class.java))
            assertEquals(/* expected= */ 1, vibratorHelper.timesVibratedWithEffect(firstTicks))
            assertEquals(/* expected= */ 1, vibratorHelper.timesVibratedWithEffect(secondTicks))
        }

    @Test
    fun playHapticAtLowerBookend_afterPlayingAtProgress_playsTwice() {
    fun playHapticAtLowerBookend_afterPlayingAtProgress_playsTwice() =
        with(kosmos) {
            // GIVEN max velocity and slider progress
            val progress = 1f
            val expectedScale =
@@ -219,7 +233,9 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_CLICK,
                    sliderHapticFeedbackProvider.scaleOnEdgeCollision(config.maxVelocityToScale),
                        sliderHapticFeedbackProvider.scaleOnEdgeCollision(
                            config.maxVelocityToScale
                        ),
                    )
                    .compose()

@@ -231,12 +247,15 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
            sliderHapticFeedbackProvider.onLowerBookend()

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

    @Test
    fun playHapticAtUpperBookend_afterPlayingAtProgress_playsTwice() {
    fun playHapticAtUpperBookend_afterPlayingAtProgress_playsTwice() =
        with(kosmos) {
            // GIVEN max velocity and slider progress
            val progress = 1f
            val expectedScale =
@@ -252,7 +271,9 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_CLICK,
                    sliderHapticFeedbackProvider.scaleOnEdgeCollision(config.maxVelocityToScale),
                        sliderHapticFeedbackProvider.scaleOnEdgeCollision(
                            config.maxVelocityToScale
                        ),
                    )
                    .compose()

@@ -264,16 +285,19 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
            sliderHapticFeedbackProvider.onUpperBookend()

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

    fun dragTextureLastProgress_afterDragTextureHaptics_keepsLastDragTextureProgress() {
    fun dragTextureLastProgress_afterDragTextureHaptics_keepsLastDragTextureProgress() =
        with(kosmos) {
            // GIVEN max velocity and a slider progress at half progress
            val progress = 0.5f

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

            // WHEN a drag texture plays
            sliderHapticFeedbackProvider.onProgress(progress)
@@ -283,12 +307,13 @@ class SliderHapticFeedbackProviderTest : SysuiTestCase() {
        }

    @Test
    fun dragTextureLastProgress_afterDragTextureHaptics_resetsOnHandleReleased() {
    fun dragTextureLastProgress_afterDragTextureHaptics_resetsOnHandleReleased() =
        with(kosmos) {
            // GIVEN max velocity and a slider progress at half progress
            val progress = 0.5f

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

            // WHEN a drag texture plays
            sliderHapticFeedbackProvider.onProgress(progress)
+40 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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

import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator

/** A simple empty vibrator required for the [FakeVibratorHelper] */
class EmptyVibrator : Vibrator() {
    override fun cancel() {}

    override fun cancel(usageFilter: Int) {}

    override fun hasAmplitudeControl(): Boolean = true

    override fun hasVibrator(): Boolean = true

    override fun vibrate(
        uid: Int,
        opPkg: String,
        vibe: VibrationEffect,
        reason: String,
        attributes: VibrationAttributes,
    ) {}
}
+78 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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

import android.annotation.SuppressLint
import android.media.AudioAttributes
import android.os.VibrationAttributes
import android.os.VibrationEffect
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock

/** A fake [VibratorHelper] that only keeps track of the latest vibration effects delivered */
@SuppressLint("VisibleForTests")
class FakeVibratorHelper : VibratorHelper(EmptyVibrator(), FakeExecutor(FakeSystemClock())) {

    /** A customizable map of primitive ids and their durations in ms */
    val primitiveDurations: HashMap<Int, Int> = ALL_PRIMITIVE_DURATIONS

    private val vibrationEffectHistory = ArrayList<VibrationEffect>()

    val totalVibrations: Int
        get() = vibrationEffectHistory.size

    override fun vibrate(effect: VibrationEffect) {
        vibrationEffectHistory.add(effect)
    }

    override fun vibrate(effect: VibrationEffect, attributes: VibrationAttributes) = vibrate(effect)

    override fun vibrate(effect: VibrationEffect, attributes: AudioAttributes) = vibrate(effect)

    override fun vibrate(
        uid: Int,
        opPkg: String?,
        vibe: VibrationEffect,
        reason: String?,
        attributes: VibrationAttributes,
    ) = vibrate(vibe)

    override fun getPrimitiveDurations(vararg primitiveIds: Int): IntArray =
        primitiveIds.map { primitiveDurations[it] ?: 0 }.toIntArray()

    fun hasVibratedWithEffects(vararg effects: VibrationEffect): Boolean =
        vibrationEffectHistory.containsAll(effects.toList())

    fun timesVibratedWithEffect(effect: VibrationEffect): Int =
        vibrationEffectHistory.count { it == effect }

    companion object {
        val ALL_PRIMITIVE_DURATIONS =
            hashMapOf(
                VibrationEffect.Composition.PRIMITIVE_NOOP to 0,
                VibrationEffect.Composition.PRIMITIVE_CLICK to 12,
                VibrationEffect.Composition.PRIMITIVE_THUD to 300,
                VibrationEffect.Composition.PRIMITIVE_SPIN to 133,
                VibrationEffect.Composition.PRIMITIVE_QUICK_RISE to 150,
                VibrationEffect.Composition.PRIMITIVE_SLOW_RISE to 500,
                VibrationEffect.Composition.PRIMITIVE_QUICK_FALL to 100,
                VibrationEffect.Composition.PRIMITIVE_TICK to 5,
                VibrationEffect.Composition.PRIMITIVE_LOW_TICK to 12,
            )
    }
}
+21 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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

import com.android.systemui.kosmos.Kosmos

var Kosmos.vibratorHelper by Kosmos.Fixture { FakeVibratorHelper() }