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

Commit f17dca1d authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez Committed by Android (Google) Code Review
Browse files

Merge "Re-design of squeeze effect haptics." into main

parents f4704d21 763551ca
Loading
Loading
Loading
Loading
+28 −25
Original line number Diff line number Diff line
@@ -72,16 +72,19 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
            VibrationEffect.Composition.PRIMITIVE_QUICK_RISE,
            VibrationEffect.Composition.PRIMITIVE_TICK,
            VibrationEffect.Composition.PRIMITIVE_CLICK,
        )
    private val invocationHaptics =
        SqueezeEffectHapticsBuilder.createInvocationHaptics(
    private val zoomOutHaptics =
        SqueezeEffectHapticsBuilder.createZoomOutEffect(
            lowTickDuration = primitiveDurations[0],
            quickRiseDuration = primitiveDurations[1],
            tickDuration = primitiveDurations[2],
            totalEffectDuration =
                DEFAULT_OUTWARD_EFFECT_DURATION_MS.toInt() +
                    DEFAULT_INWARD_EFFECT_DURATION_MILLIS.toInt(),
            effectDuration =
                (TopLevelWindowEffects.HAPTIC_OUTWARD_EFFECT_DURATION_SCALE *
                        DEFAULT_OUTWARD_EFFECT_DURATION_MS)
                    .toInt(),
        )
    private val lppIndicatorEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect()

    private val Kosmos.underTest by
        Kosmos.Fixture {
@@ -134,7 +137,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            animatorTestRule.advanceTimeBy(1)

            assertNotEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
        }

    @Test
@@ -153,7 +155,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            animatorTestRule.advanceTimeBy(1)

            assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse()
        }

    @Test
@@ -177,7 +179,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            animatorTestRule.advanceTimeBy(1)

            assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse()
        }

    @Test
@@ -194,15 +196,11 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            // add additional 1ms time to simulate initial delay duration has passed
            advanceTime((expectedDelay + 1).milliseconds)
            animatorTestRule.advanceTimeBy(1)
            val timesCancelledBefore = vibratorHelper.timesCancelled

            fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value =
                false
            runCurrent()
            animatorTestRule.advanceTimeBy(1)

            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1)
        }

    @Test
@@ -228,7 +226,9 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            runCurrent()
            animatorTestRule.advanceTimeBy(1)

            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration))
                .isFalse()
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1)
        }

@@ -254,7 +254,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            runCurrent()

            assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse()
        }

    @Test
@@ -273,7 +273,7 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            animatorTestRule.advanceTimeBy(1)

            assertEquals(0f, kosmos.fakeAppZoomOut.lastTopLevelProgress)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isFalse()
        }
    }

@@ -295,7 +295,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            runCurrent()
            val timesCancelledBefore = vibratorHelper.timesCancelled
            assertThat(fakeAppZoomOut.lastTopLevelProgress).isGreaterThan(0f)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()

            // Simulate power button long press
            fakeSqueezeEffectRepository.isPowerButtonLongPressed.value = true
@@ -306,8 +305,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
                false
            runCurrent() // Triggers cancelSqueeze, but it should not interrupt

            // Animation should be non-interruptible, so haptics are not cancelled at this point
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore)
            // On long-press the LPP haptic effect plays cancelling any previous haptic effect
            var expectedCancellations = timesCancelledBefore + 1
            assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations)
            assertThat(vibratorHelper.hasVibratedWithEffects(lppIndicatorEffect.vibration)).isTrue()

            // Animation continues: complete inward animation
            animatorTestRule.advanceTimeBy(DEFAULT_INWARD_EFFECT_DURATION_MILLIS - 10L)
@@ -315,12 +316,14 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(1f)

            // Animation continues: complete outward animation (triggered by inward animation's end)
            // ZoomOut haptics have been played at this point after cancelling any previous
            // vibration job
            animatorTestRule.advanceTimeBy(DEFAULT_OUTWARD_EFFECT_DURATION_MS)
            expectedCancellations++
            runCurrent()
            assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f)

            // Haptics never cancelled when animation completes
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore)
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue()
            assertThat(vibratorHelper.timesCancelled).isEqualTo(expectedCancellations)
        }

    @Test
@@ -376,7 +379,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            runCurrent()
            // Advance past initial delay
            advanceTime((initialDelay + 1).milliseconds)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
            val timesCancelledBefore = vibratorHelper.timesCancelled

            // Complete inward animation
@@ -389,8 +391,10 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            runCurrent()
            assertThat(fakeAppZoomOut.lastTopLevelProgress).isEqualTo(0f)

            // Haptics are not cancelled when animation completes without interruption
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore)
            // ZoomOut Haptics play and are not cancelled when animation completes without
            // interruption
            assertThat(vibratorHelper.timesCancelled).isEqualTo(timesCancelledBefore + 1)
            assertThat(vibratorHelper.hasVibratedWithEffects(zoomOutHaptics.vibration)).isTrue()

            // Release power button (does not affect completed animation)
            fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value =
@@ -420,7 +424,6 @@ class TopLevelWindowEffectsTest : SysuiTestCase() {
            val progressBeforeCancel = fakeAppZoomOut.lastTopLevelProgress
            assertThat(progressBeforeCancel).isGreaterThan(0f)
            assertThat(progressBeforeCancel).isLessThan(1f)
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()

            // Release power button before long press is detected
            fakeSqueezeEffectRepository.isEffectEnabledAndPowerButtonPressedAsSingleGesture.value =
+24 −3
Original line number Diff line number Diff line
@@ -16,6 +16,7 @@

package com.android.systemui.topwindoweffects

import android.os.SystemProperties
import androidx.annotation.VisibleForTesting
import androidx.core.animation.Animator
import androidx.core.animation.AnimatorListenerAdapter
@@ -79,7 +80,13 @@ constructor(
            squeezeEffectInteractor.isEffectEnabledAndPowerButtonPressedAsSingleGesture
                .collectLatest { enabledAndPressed ->
                    if (enabledAndPressed) {
                        startSqueeze()
                        val hapticsOption =
                            SystemProperties.get(
                                /*key=*/ "persist.lpp_invocation.haptics",
                                /*def=*/ "no_rumble",
                            )
                        val useHapticRumble = hapticsOption == "with_rumble"
                        startSqueeze(useHapticRumble)
                    } else {
                        cancelSqueeze()
                    }
@@ -87,18 +94,25 @@ constructor(
        }
    }

    private suspend fun startSqueeze() {
    private suspend fun startSqueeze(useHapticRumble: Boolean) {
        delay(squeezeEffectInteractor.getInvocationEffectInitialDelayMillis())
        setRequestTopUi(true)
        val inwardsAnimationDuration =
            squeezeEffectInteractor.getInvocationEffectInAnimationDurationMillis()
        val outwardsAnimationDuration =
            squeezeEffectInteractor.getInvocationEffectOutAnimationDurationMillis()
        if (useHapticRumble) {
            hapticPlayer?.playRumble(inwardsAnimationDuration.toInt())
        }
        animateSqueezeProgressTo(
            targetProgress = 1f,
            duration = inwardsAnimationDuration,
            interpolator = InterpolatorsAndroidX.LEGACY,
        ) {
            hapticPlayer?.startZoomOutEffect(
                durationMillis =
                    (HAPTIC_OUTWARD_EFFECT_DURATION_SCALE * outwardsAnimationDuration).toInt()
            )
            animateSqueezeProgressTo(
                targetProgress = 0f,
                duration = outwardsAnimationDuration,
@@ -107,10 +121,10 @@ constructor(
                finishAnimation()
            }
        }
        hapticPlayer?.start(inwardsAnimationDuration.toInt() + outwardsAnimationDuration.toInt())
        squeezeEffectInteractor.isPowerButtonLongPressed.collectLatest { isLongPressed ->
            if (isLongPressed) {
                isAnimationInterruptible = false
                hapticPlayer?.playLppIndicator()
            }
        }
    }
@@ -173,6 +187,13 @@ constructor(

    companion object {
        @VisibleForTesting const val TAG = "TopLevelWindowEffects"

        /**
         * A scale applied to the outward animation duration to derive the duration of the haptic
         * effect. This number is fine tuned to produce a haptic effect that suits the outward
         * animator interpolator well.
         */
        @VisibleForTesting const val HAPTIC_OUTWARD_EFFECT_DURATION_SCALE = 0.53
    }
}

+30 −11
Original line number Diff line number Diff line
@@ -41,41 +41,60 @@ constructor(
            VibrationEffect.Composition.PRIMITIVE_TICK,
        )

    private fun buildInvocationHaptics(totalDurationMillis: Int) =
        SqueezeEffectHapticsBuilder.createInvocationHaptics(
    private fun buildZoomOutHaptics(durationMillis: Int) =
        SqueezeEffectHapticsBuilder.createZoomOutEffect(
            lowTickDuration = primitiveDurations[0],
            quickRiseDuration = primitiveDurations[1],
            tickDuration = primitiveDurations[2],
            totalEffectDuration = totalDurationMillis,
            effectDuration = durationMillis,
        )

    private val lppIndicationEffect = SqueezeEffectHapticsBuilder.createLppIndicatorEffect()

    private var vibrationJob: Job? = null

    fun start(totalDurationMillis: Int) {
    fun startZoomOutEffect(durationMillis: Int) {
        cancel()
        val invocationHaptics = buildInvocationHaptics(totalDurationMillis)
        if (invocationHaptics.initialDelay <= 0) {
            vibrate(invocationHaptics.vibration)
        val zoomOutHaptics = buildZoomOutHaptics(durationMillis)
        if (zoomOutHaptics.initialDelay <= 0) {
            vibrate(zoomOutHaptics)
        } else {
            vibrationJob =
                applicationScope.launch {
                    delay(invocationHaptics.initialDelay.toLong())
                    delay(zoomOutHaptics.initialDelay.toLong())
                    if (isActive) {
                        vibrate(invocationHaptics.vibration)
                        vibrate(zoomOutHaptics)
                    }
                    vibrationJob = null
                }
        }
    }

    fun playRumble(rumbleDuration: Int) {
        val effect =
            SqueezeEffectHapticsBuilder.createRumbleEffect(
                rumbleDuration = rumbleDuration,
                lowTickDuration = primitiveDurations[0],
            )
        vibrate(effect)
    }

    fun playLppIndicator() {
        vibratorHelper.cancel()
        vibrate(lppIndicationEffect)
    }

    fun cancel() {
        vibrationJob?.cancel()
        vibrationJob = null
        vibratorHelper.cancel()
    }

    private fun vibrate(vibrationEffect: VibrationEffect) =
        vibratorHelper.vibrate(vibrationEffect, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES)
    private fun vibrate(effect: SqueezeEffectHapticsBuilder.SqueezeEffectHaptics?) {
        effect?.let {
            vibratorHelper.vibrate(it.vibration, SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES)
        }
    }

    @AssistedFactory
    interface Factory {
+78 −23
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ package com.android.systemui.topwindoweffects.ui.viewmodel

import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.VibrationEffect.Composition
import android.util.Log

object SqueezeEffectHapticsBuilder {
@@ -27,15 +28,27 @@ object SqueezeEffectHapticsBuilder {
    private const val LOW_TICK_SCALE = 0.09f
    private const val QUICK_RISE_SCALE = 0.25f
    private const val TICK_SCALE = 1f
    private const val CLICK_SCALE = 0.8f

    val VIBRATION_ATTRIBUTES =
        VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_HARDWARE_FEEDBACK).build()

    fun createInvocationHaptics(
    fun createRumbleEffect(rumbleDuration: Int, lowTickDuration: Int): SqueezeEffectHaptics? {
        // If the lowTickDuration is zero, the effect is not supported
        if (lowTickDuration == 0) {
            Log.d(TAG, "The LOW_TICK, primitive is not supported. No rumble added")
            return null
        }
        val composition = VibrationEffect.startComposition()
        addRumble(rumbleDuration, lowTickDuration, composition)
        return SqueezeEffectHaptics(composition.compose())
    }

    fun createZoomOutEffect(
        lowTickDuration: Int,
        quickRiseDuration: Int,
        tickDuration: Int,
        totalEffectDuration: Int,
        effectDuration: Int,
    ): SqueezeEffectHaptics {
        // If a primitive is not supported, the duration will be 0
        val isInvocationEffectSupported =
@@ -53,32 +66,74 @@ object SqueezeEffectHapticsBuilder {
            // We use the full invocation duration as a delay so that we play the
            // HEAVY_CLICK fallback in sync with the end of the squeeze effect
            return SqueezeEffectHaptics(
                initialDelay = totalEffectDuration,
                initialDelay = effectDuration,
                vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK),
            )
        }

        val riseEffectDuration = quickRiseDuration + RISE_TO_TICK_DELAY + tickDuration
        val warmUpTime = totalEffectDuration - riseEffectDuration
        val nLowTicks = warmUpTime / lowTickDuration
        if (effectDuration < riseEffectDuration) {
            Log.d(
                TAG,
                """
                The rise effect($riseEffectDuration ms) is longer than the total zoom-out effect
                ($effectDuration ms). Using EFFECT_HEAVY_CLICK as a fallback.
                """
                    .trimIndent(),
            )
            return SqueezeEffectHaptics(
                initialDelay = effectDuration,
                vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK),
            )
        }

        val composition =
            VibrationEffect.startComposition().apply {
                // Warmup low ticks
                repeat(nLowTicks) {
                    addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, LOW_TICK_SCALE, 0)
        val composition = VibrationEffect.startComposition()

        // Rumble towards the end of the zoom-out
        val zoomOutRumbleDuration = effectDuration - riseEffectDuration
        addRumble(zoomOutRumbleDuration, lowTickDuration, composition)

        // Final rise effect
        addQuickRiseTickEffect(composition)

        return SqueezeEffectHaptics(composition.compose())
    }
                // Quick rise and tick
                addPrimitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, 0)
                addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_TICK,
                    TICK_SCALE,
                    RISE_TO_TICK_DELAY,

    /**
     * Add a rumble to a vibration composition. The rumble is a composition of LOW_TICK primitives.
     * This assumes that the device can play the LOW_TICK primitive.
     */
    private fun addRumble(rumbleDuration: Int, lowTickDuration: Int, composition: Composition) {
        val nLowTicks = (rumbleDuration / lowTickDuration).coerceAtLeast(minimumValue = 0)
        repeat(nLowTicks) {
            composition.addPrimitive(
                Composition.PRIMITIVE_LOW_TICK,
                /*scale=*/ LOW_TICK_SCALE,
                /*delay=*/ 0,
            )
        }
    }

    /**
     * Add a quick rise and a tick to a vibration composition. This assumes that the QUICK_RISE and
     * TICK primitives are supported.
     */
    private fun addQuickRiseTickEffect(composition: Composition) {
        composition.addPrimitive(Composition.PRIMITIVE_QUICK_RISE, QUICK_RISE_SCALE, /* delay= */ 0)
        composition.addPrimitive(Composition.PRIMITIVE_TICK, TICK_SCALE, RISE_TO_TICK_DELAY)
    }

        return SqueezeEffectHaptics(initialDelay = 0, vibration = composition.compose())
    fun createLppIndicatorEffect(): SqueezeEffectHaptics {
        val composition = VibrationEffect.startComposition()
        composition.addPrimitive(
            Composition.PRIMITIVE_CLICK,
            /*scale=*/ CLICK_SCALE,
            /*delay=*/ 0,
        )
        return SqueezeEffectHaptics(composition.compose())
    }

    data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect)
    data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect) {
        constructor(vibration: VibrationEffect) : this(initialDelay = 0, vibration)
    }
}