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

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

Merge "[SysUI][Floaty] Adding squeeze effect haptics." into main

parents 54f83995 466abcd4
Loading
Loading
Loading
Loading
+127 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.topwindoweffects.ui.viewmodel

import android.os.VibrationEffect
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.fakeVibratorHelper
import com.android.systemui.keyevent.data.repository.fakeKeyEventRepository
import com.android.systemui.kosmos.testScope
import com.android.systemui.kosmos.useUnconfinedTestDispatcher
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.android.systemui.topwindoweffects.data.repository.fakeSqueezeEffectRepository
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(AndroidJUnit4::class)
class SqueezeEffectHapticPlayerTest : SysuiTestCase() {

    private val kosmos = testKosmos().useUnconfinedTestDispatcher()
    private val initialDelay = 100L
    private val vibratorHelper = kosmos.fakeVibratorHelper
    private val squeezeEffectRepository = kosmos.fakeSqueezeEffectRepository
    private val keyEventRepository = kosmos.fakeKeyEventRepository
    private val primitiveDurations =
        vibratorHelper.getPrimitiveDurations(
            VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
            VibrationEffect.Composition.PRIMITIVE_QUICK_RISE,
            VibrationEffect.Composition.PRIMITIVE_TICK,
        )
    private val invocationHaptics =
        SqueezeEffectHapticsBuilder.createInvocationHaptics(
            lowTickDuration = primitiveDurations[0],
            quickRiseDuration = primitiveDurations[1],
            tickDuration = primitiveDurations[2],
        )
    private val underTest = kosmos.squeezeEffectHapticPlayerFactory.create()

    @Before
    fun setUp() {
        squeezeEffectRepository.isSqueezeEffectEnabled.value = true
        squeezeEffectRepository.invocationEffectInitialDelayMs = initialDelay
        underTest.activateIn(kosmos.testScope)
    }

    @Test
    fun onDown_beforeLongPress_invocationHapticsPlay() =
        kosmos.testScope.runTest {
            val powerButtonLongPress by
                collectLastValue(keyEventRepository.isPowerButtonLongPressed)
            keyEventRepository.setPowerButtonDown(true)

            advanceTimeBy(initialDelay + 1)
            runCurrent()

            assertThat(powerButtonLongPress).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
        }

    @Test
    fun onRelease_beforeLongPress_invocationHapticsCancel() =
        kosmos.testScope.runTest {
            val powerButtonLongPress by
                collectLastValue(keyEventRepository.isPowerButtonLongPressed)
            keyEventRepository.setPowerButtonDown(true)

            advanceTimeBy(initialDelay + 1)
            runCurrent()

            keyEventRepository.setPowerButtonDown(false)
            runCurrent()

            assertThat(powerButtonLongPress).isFalse()
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
            // From the initial collection of the state flow and the interruption, two cancellations
            // are expected
            assertThat(vibratorHelper.timesCancelled).isEqualTo(2)
        }

    @Test
    fun onRelease_afterLongPress_invocationHapticsDoesNotCancel() =
        kosmos.testScope.runTest {
            val powerButtonLongPress by
                collectLastValue(keyEventRepository.isPowerButtonLongPressed)
            keyEventRepository.setPowerButtonDown(true)

            advanceTimeBy(initialDelay + 1)
            runCurrent()

            keyEventRepository.setPowerButtonLongPressed(true)
            runCurrent()

            keyEventRepository.setPowerButtonDown(false)
            runCurrent()

            assertThat(powerButtonLongPress).isTrue()
            assertThat(vibratorHelper.hasVibratedWithEffects(invocationHaptics.vibration)).isTrue()
            // From the initial collection of the state flow and the interruption, only one
            // cancellation is expected
            assertThat(vibratorHelper.timesCancelled).isEqualTo(1)
        }
}
+16 −3
Original line number Original line Diff line number Diff line
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
import com.android.internal.jank.Cuj
import com.android.internal.jank.Cuj
import com.android.internal.jank.InteractionJankMonitor
import com.android.internal.jank.InteractionJankMonitor
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.lifecycle.rememberViewModel
import com.android.systemui.topwindoweffects.ui.viewmodel.SqueezeEffectConfig
import com.android.systemui.topwindoweffects.ui.viewmodel.SqueezeEffectViewModel
import com.android.systemui.topwindoweffects.ui.viewmodel.SqueezeEffectViewModel
import com.android.wm.shell.appzoomout.AppZoomOut
import com.android.wm.shell.appzoomout.AppZoomOut
import java.util.Optional
import java.util.Optional
@@ -106,19 +107,31 @@ fun SqueezeEffect(
    LaunchedEffect(isMainAnimationRunning) {
    LaunchedEffect(isMainAnimationRunning) {
        if (isMainAnimationRunning) {
        if (isMainAnimationRunning) {
            interactionJankMonitor.begin(view, Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
            interactionJankMonitor.begin(view, Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
            squeezeProgress.animateTo(1f, animationSpec = tween(durationMillis = 800))
            squeezeProgress.animateTo(
            squeezeProgress.animateTo(0f, animationSpec = tween(durationMillis = 333))
                1f,
                animationSpec = tween(durationMillis = SqueezeEffectConfig.INWARD_EFFECT_DURATION),
            )
            squeezeProgress.animateTo(
                0f,
                animationSpec = tween(durationMillis = SqueezeEffectConfig.OUTWARD_EFFECT_DURATION),
            )
            if (squeezeProgress.value == 0f) {
            if (squeezeProgress.value == 0f) {
                interactionJankMonitor.end(Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
                interactionJankMonitor.end(Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
                viewModel.onSqueezeEffectEnd()
                onEffectFinished()
                onEffectFinished()
            }
            }
            isAnimationInterruptible = true
            isAnimationInterruptible = true
        } else {
        } else {
            if (squeezeProgress.value != 0f) {
            if (squeezeProgress.value != 0f) {
                squeezeProgress.animateTo(0f, animationSpec = tween(durationMillis = 333))
                squeezeProgress.animateTo(
                    0f,
                    animationSpec =
                        tween(durationMillis = SqueezeEffectConfig.OUTWARD_EFFECT_DURATION),
                )
            }
            }
            if (squeezeProgress.value == 0f) {
            if (squeezeProgress.value == 0f) {
                interactionJankMonitor.cancel(Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
                interactionJankMonitor.cancel(Cuj.CUJ_LPP_ASSIST_INVOCATION_EFFECT)
                viewModel.onSqueezeEffectEnd()
                onEffectFinished()
                onEffectFinished()
            }
            }
        }
        }
+23 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.topwindoweffects.ui.viewmodel

// A container of durations for the effect. Used by both the visual and haptic effects.
object SqueezeEffectConfig {
    const val INWARD_EFFECT_DURATION = 800 // in milliseconds
    const val OUTWARD_EFFECT_DURATION = 333 // in milliseconds
}
+116 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.topwindoweffects.ui.viewmodel

import android.os.VibrationEffect
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.systemui.keyevent.domain.interactor.KeyEventInteractor
import com.android.systemui.lifecycle.ExclusiveActivatable
import com.android.systemui.statusbar.VibratorHelper
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Job
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged

class SqueezeEffectHapticPlayer
@AssistedInject
constructor(keyEventInteractor: KeyEventInteractor, private val vibratorHelper: VibratorHelper) :
    ExclusiveActivatable() {

    private val primitiveDurations =
        vibratorHelper.getPrimitiveDurations(
            VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
            VibrationEffect.Composition.PRIMITIVE_QUICK_RISE,
            VibrationEffect.Composition.PRIMITIVE_TICK,
        )
    private val invocationHaptics =
        SqueezeEffectHapticsBuilder.createInvocationHaptics(
            lowTickDuration = primitiveDurations[0],
            quickRiseDuration = primitiveDurations[1],
            tickDuration = primitiveDurations[2],
        )
    private var invocationJob: Job? = null
    private var canInterruptHaptics = true

    private val powerButtonState =
        combine(
                keyEventInteractor.isPowerButtonDown,
                keyEventInteractor.isPowerButtonLongPressed,
            ) { down, longPressed ->
                PowerButtonState(down, longPressed)
            }
            .distinctUntilChanged()

    override suspend fun onActivated(): Nothing {
        coroutineScope {
            launch(spanName = "$TAG#powerButtonState") {
                powerButtonState.collect { state ->
                    when {
                        !state.down && !state.longPressed -> interruptInvocationHaptics()
                        state.down && !state.longPressed -> beginInvocationHaptics()
                        state.down && state.longPressed -> canInterruptHaptics = false
                    }
                }
            }
            awaitCancellation()
        }
    }

    private suspend fun beginInvocationHaptics() {
        if (invocationJob != null && invocationJob?.isActive == true) return
        coroutineScope {
            invocationJob =
                launch(spanName = "$TAG#beginInvocationHaptics") {
                    if (invocationHaptics.initialDelay != 0) {
                        delay(invocationHaptics.initialDelay.toLong())
                    }
                    if (isActive) {
                        vibratorHelper.vibrate(
                            invocationHaptics.vibration,
                            SqueezeEffectHapticsBuilder.VIBRATION_ATTRIBUTES,
                        )
                    }
                }
        }
    }

    private fun interruptInvocationHaptics() {
        if (!canInterruptHaptics) return
        vibratorHelper.cancel()
        invocationJob?.cancel()
        invocationJob = null
    }

    fun onSqueezeEffectEnd() {
        canInterruptHaptics = true
    }

    private data class PowerButtonState(val down: Boolean, val longPressed: Boolean)

    @AssistedFactory
    interface Factory {
        fun create(): SqueezeEffectHapticPlayer
    }

    companion object {
        private const val TAG = "SqueezeEffectHapticPlayer"
    }
}
+85 −0
Original line number Original line Diff line number Diff line
/*
 * Copyright (C) 2025 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.topwindoweffects.ui.viewmodel

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

object SqueezeEffectHapticsBuilder {

    private const val TAG = "SqueezeEffectHapticsBuilder"
    private const val RISE_TO_TICK_DELAY = 50 // in milliseconds
    private const val LOW_TICK_SCALE = 0.09f
    private const val QUICK_RISE_SCALE = 0.25f
    private const val TICK_SCALE = 1f

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

    fun createInvocationHaptics(
        lowTickDuration: Int,
        quickRiseDuration: Int,
        tickDuration: Int,
    ): SqueezeEffectHaptics {
        val totalEffectDuration =
            SqueezeEffectConfig.INWARD_EFFECT_DURATION + SqueezeEffectConfig.OUTWARD_EFFECT_DURATION
        // If a primitive is not supported, the duration will be 0
        val isInvocationEffectSupported =
            lowTickDuration != 0 && quickRiseDuration != 0 && tickDuration != 0

        if (!isInvocationEffectSupported) {
            Log.d(
                TAG,
                """
                    The LOW_TICK, TICK and/or QUICK_RISE primitives are not supported.
                    Using EFFECT_HEAVY_CLICK as a fallback."
                """
                    .trimIndent(),
            )
            // 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,
                vibration = VibrationEffect.get(VibrationEffect.EFFECT_HEAVY_CLICK),
            )
        }

        val riseEffectDuration = quickRiseDuration + RISE_TO_TICK_DELAY + tickDuration
        val warmUpTime = totalEffectDuration - riseEffectDuration
        val nLowTicks = warmUpTime / lowTickDuration

        val composition =
            VibrationEffect.startComposition().apply {
                // Warmup low ticks
                repeat(nLowTicks) {
                    addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, LOW_TICK_SCALE, 0)
                }
                // 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,
                )
            }

        return SqueezeEffectHaptics(initialDelay = 0, vibration = composition.compose())
    }

    data class SqueezeEffectHaptics(val initialDelay: Int, val vibration: VibrationEffect)
}
Loading