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

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

Merge "Adding MSDL haptics to interactions with lockscreen shortcuts." into main

parents d9381ac8 96cb6540
Loading
Loading
Loading
Loading
+116 −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.keyguard.data.quickaffordance

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.keyguard.domain.interactor.keyguardQuickAffordanceHapticViewModelFactory
import com.android.systemui.keyguard.domain.interactor.keyguardQuickAffordanceInteractor
import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceHapticViewModel
import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceViewModel
import com.android.systemui.kosmos.testScope
import com.android.systemui.shared.keyguard.shared.model.KeyguardQuickAffordanceSlots
import com.android.systemui.testKosmos
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith

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

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val slotId = KeyguardQuickAffordanceSlots.SLOT_ID_BOTTOM_START
    private val configKey = "$slotId::home"
    private val keyguardQuickAffordanceInteractor = kosmos.keyguardQuickAffordanceInteractor
    private val viewModelFlow =
        MutableStateFlow(KeyguardQuickAffordanceViewModel(configKey = configKey, slotId = slotId))

    private val underTest =
        kosmos.keyguardQuickAffordanceHapticViewModelFactory.create(viewModelFlow)

    @Test
    fun whenLaunchingFromTriggeredResult_hapticStateIsLaunch() =
        testScope.runTest {
            // GIVEN that the result from triggering the affordance launched an activity or dialog
            val hapticState by collectLastValue(underTest.quickAffordanceHapticState)
            keyguardQuickAffordanceInteractor.setLaunchingFromTriggeredResult(
                KeyguardQuickAffordanceConfig.LaunchingFromTriggeredResult(true, configKey)
            )
            runCurrent()

            // THEN the haptic state indicates that a launch haptics must play
            assertThat(hapticState)
                .isEqualTo(KeyguardQuickAffordanceHapticViewModel.HapticState.LAUNCH)
        }

    @Test
    fun whenNotLaunchFromTriggeredResult_hapticStateDoesNotEmit() =
        testScope.runTest {
            // GIVEN that the result from triggering the affordance did not launch an activity or
            // dialog
            val hapticState by collectLastValue(underTest.quickAffordanceHapticState)
            keyguardQuickAffordanceInteractor.setLaunchingFromTriggeredResult(
                KeyguardQuickAffordanceConfig.LaunchingFromTriggeredResult(false, configKey)
            )
            runCurrent()

            // THEN there is no haptic state to play any feedback
            assertThat(hapticState)
                .isEqualTo(KeyguardQuickAffordanceHapticViewModel.HapticState.NO_HAPTICS)
        }

    @Test
    fun onQuickAffordanceTogglesToActivated_hapticStateIsToggleOn() =
        testScope.runTest {
            // GIVEN that an affordance toggles from deactivated to activated
            val hapticState by collectLastValue(underTest.quickAffordanceHapticState)
            toggleQuickAffordance(on = true)

            // THEN the haptic state reflects that a toggle on haptics should play
            assertThat(hapticState)
                .isEqualTo(KeyguardQuickAffordanceHapticViewModel.HapticState.TOGGLE_ON)
        }

    @Test
    fun onQuickAffordanceTogglesToDeactivated_hapticStateIsToggleOff() =
        testScope.runTest {
            // GIVEN that an affordance toggles from activated to deactivated
            val hapticState by collectLastValue(underTest.quickAffordanceHapticState)
            toggleQuickAffordance(on = false)

            // THEN the haptic state reflects that a toggle off haptics should play
            assertThat(hapticState)
                .isEqualTo(KeyguardQuickAffordanceHapticViewModel.HapticState.TOGGLE_OFF)
        }

    private fun TestScope.toggleQuickAffordance(on: Boolean) {
        underTest.updateActivatedHistory(!on)
        runCurrent()
        underTest.updateActivatedHistory(on)
        runCurrent()
    }
}
+18 −23
Original line number Diff line number Diff line
@@ -17,12 +17,23 @@
package com.android.systemui.keyguard.ui.binder

import android.os.VibrationEffect
import com.android.systemui.Flags
import kotlin.time.Duration.Companion.milliseconds

object KeyguardBottomAreaVibrations {

    val ShakeAnimationDuration = 300.milliseconds
    const val ShakeAnimationCycles = 5f
    val ShakeAnimationDuration =
        if (Flags.msdlFeedback()) {
            285.milliseconds
        } else {
            300.milliseconds
        }
    val ShakeAnimationCycles =
        if (Flags.msdlFeedback()) {
            3f
        } else {
            5f
        }

    private const val SmallVibrationScale = 0.3f
    private const val BigVibrationScale = 0.6f
@@ -47,29 +58,13 @@ object KeyguardBottomAreaVibrations {

    val Activated =
        VibrationEffect.startComposition()
            .addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_TICK,
                BigVibrationScale,
                0,
            )
            .addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_QUICK_RISE,
                0.1f,
                0,
            )
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_TICK, BigVibrationScale, 0)
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_QUICK_RISE, 0.1f, 0)
            .compose()

    val Deactivated =
        VibrationEffect.startComposition()
            .addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_TICK,
                BigVibrationScale,
                0,
            )
            .addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_QUICK_FALL,
                0.1f,
                0,
            )
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_TICK, BigVibrationScale, 0)
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.1f, 0)
            .compose()
}
+69 −25
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ package com.android.systemui.keyguard.ui.binder
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.drawable.Animatable2
import android.os.VibrationEffect
import android.util.Size
import android.view.View
import android.view.ViewGroup
@@ -33,25 +34,27 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.app.tracing.coroutines.launchTraced as launch
import com.android.keyguard.logging.KeyguardQuickAffordancesLogger
import com.android.systemui.Flags
import com.android.systemui.animation.Expandable
import com.android.systemui.animation.view.LaunchableImageView
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.binder.IconViewBinder
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceHapticViewModel
import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceViewModel
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.plugins.FalsingManager
import com.android.systemui.res.R
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.doOnEnd
import com.google.android.msdl.data.model.MSDLToken
import com.google.android.msdl.domain.MSDLPlayer
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import com.android.app.tracing.coroutines.launchTraced as launch

/** This is only for a SINGLE Quick affordance */
@SysUISingleton
@@ -60,8 +63,9 @@ class KeyguardQuickAffordanceViewBinder
constructor(
    private val falsingManager: FalsingManager?,
    private val vibratorHelper: VibratorHelper?,
    private val msdlPlayer: MSDLPlayer,
    private val logger: KeyguardQuickAffordancesLogger,
    @Main private val mainImmediateDispatcher: CoroutineDispatcher,
    private val hapticsViewModelFactory: KeyguardQuickAffordanceHapticViewModel.Factory,
) {

    private val EXIT_DOZE_BUTTON_REVEAL_ANIMATION_DURATION_MS = 250L
@@ -88,6 +92,12 @@ constructor(
    ): Binding {
        val button = view as ImageView
        val configurationBasedDimensions = MutableStateFlow(loadFromResources(view))
        val hapticsViewModel =
            if (Flags.msdlFeedback()) {
                hapticsViewModelFactory.create(viewModel)
            } else {
                null
            }
        val disposableHandle =
            view.repeatWhenAttached {
                repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -98,15 +108,12 @@ constructor(
                                viewModel = buttonModel,
                                messageDisplayer = messageDisplayer,
                            )
                            hapticsViewModel?.updateActivatedHistory(buttonModel.isActivated)
                        }
                    }

                    launch {
                        updateButtonAlpha(
                            view = button,
                            viewModel = viewModel,
                            alphaFlow = alpha,
                        )
                        updateButtonAlpha(view = button, viewModel = viewModel, alphaFlow = alpha)
                    }

                    launch {
@@ -117,6 +124,32 @@ constructor(
                            }
                        }
                    }

                    if (Flags.msdlFeedback()) {
                        launch {
                            hapticsViewModel
                                ?.quickAffordanceHapticState
                                ?.filter {
                                    it !=
                                        KeyguardQuickAffordanceHapticViewModel.HapticState
                                            .NO_HAPTICS
                                }
                                ?.collect { state ->
                                    when (state) {
                                        KeyguardQuickAffordanceHapticViewModel.HapticState
                                            .TOGGLE_ON -> msdlPlayer.playToken(MSDLToken.SWITCH_ON)
                                        KeyguardQuickAffordanceHapticViewModel.HapticState
                                            .TOGGLE_OFF ->
                                            msdlPlayer.playToken(MSDLToken.SWITCH_OFF)
                                        KeyguardQuickAffordanceHapticViewModel.HapticState.LAUNCH ->
                                            msdlPlayer.playToken(MSDLToken.LONG_PRESS)
                                        KeyguardQuickAffordanceHapticViewModel.HapticState
                                            .NO_HAPTICS -> Unit
                                    }
                                    hapticsViewModel.resetLaunchingFromTriggeredResult()
                                }
                        }
                    }
                }
            }

@@ -178,7 +211,7 @@ constructor(
                    com.android.internal.R.color.materialColorOnPrimaryFixed
                } else {
                    com.android.internal.R.color.materialColorOnSurface
                },
                }
            )
        )

@@ -221,12 +254,7 @@ constructor(
                            .getDimensionPixelSize(R.dimen.keyguard_affordance_shake_amplitude)
                            .toFloat()
                    val shakeAnimator =
                        ObjectAnimator.ofFloat(
                            view,
                            "translationX",
                            -amplitude / 2,
                            amplitude / 2,
                        )
                        ObjectAnimator.ofFloat(view, "translationX", -amplitude / 2, amplitude / 2)
                    shakeAnimator.duration =
                        KeyguardBottomAreaVibrations.ShakeAnimationDuration.inWholeMilliseconds
                    shakeAnimator.interpolator =
@@ -234,11 +262,17 @@ constructor(
                    shakeAnimator.doOnEnd { view.translationX = 0f }
                    shakeAnimator.start()

                    vibratorHelper?.vibrate(KeyguardBottomAreaVibrations.Shake)
                    vibratorHelper?.playFeedback(KeyguardBottomAreaVibrations.Shake, msdlPlayer)
                    logger.logQuickAffordanceTapped(viewModel.configKey)
                }
                view.onLongClickListener =
                    OnLongClickListener(falsingManager, viewModel, vibratorHelper, onTouchListener)
                    OnLongClickListener(
                        falsingManager,
                        viewModel,
                        vibratorHelper,
                        onTouchListener,
                        msdlPlayer,
                    )
            } else {
                view.setOnClickListener(OnClickListener(viewModel, checkNotNull(falsingManager)))
            }
@@ -268,7 +302,7 @@ constructor(
                Size(
                    view.resources.getDimensionPixelSize(R.dimen.keyguard_affordance_fixed_width),
                    view.resources.getDimensionPixelSize(R.dimen.keyguard_affordance_fixed_height),
                ),
                )
        )
    }

@@ -297,7 +331,8 @@ constructor(
        private val falsingManager: FalsingManager?,
        private val viewModel: KeyguardQuickAffordanceViewModel,
        private val vibratorHelper: VibratorHelper?,
        private val onTouchListener: KeyguardQuickAffordanceOnTouchListener
        private val onTouchListener: KeyguardQuickAffordanceOnTouchListener,
        private val msdlPlayer: MSDLPlayer,
    ) : View.OnLongClickListener {
        override fun onLongClick(view: View): Boolean {
            if (falsingManager?.isFalseLongTap(FalsingManager.MODERATE_PENALTY) == true) {
@@ -312,12 +347,13 @@ constructor(
                        slotId = viewModel.slotId,
                    )
                )
                vibratorHelper?.vibrate(
                vibratorHelper?.playFeedback(
                    if (viewModel.isActivated) {
                        KeyguardBottomAreaVibrations.Activated
                    } else {
                        KeyguardBottomAreaVibrations.Deactivated
                    }
                    },
                    msdlPlayer,
                )
            }

@@ -328,7 +364,15 @@ constructor(
        override fun onLongClickUseDefaultHapticFeedback(view: View) = false
    }

    private data class ConfigurationBasedDimensions(
        val buttonSizePx: Size,
    )
    private data class ConfigurationBasedDimensions(val buttonSizePx: Size)
}

private fun VibratorHelper.playFeedback(effect: VibrationEffect, msdlPlayer: MSDLPlayer) {
    if (!Flags.msdlFeedback()) {
        vibrate(effect)
    } else {
        if (effect == KeyguardBottomAreaVibrations.Shake) {
            msdlPlayer.playToken(MSDLToken.FAILURE)
        }
    }
}
+96 −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.keyguard.ui.viewmodel

import com.android.systemui.keyguard.domain.interactor.KeyguardQuickAffordanceInteractor
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge

class KeyguardQuickAffordanceHapticViewModel
@AssistedInject
constructor(
    @Assisted quickAffordanceViewModel: Flow<KeyguardQuickAffordanceViewModel>,
    private val quickAffordanceInteractor: KeyguardQuickAffordanceInteractor,
) {

    private val activatedHistory = MutableStateFlow(ActivatedHistory(false))

    private val launchingHapticState: Flow<HapticState> =
        combine(
                quickAffordanceViewModel.map { it.configKey },
                quickAffordanceInteractor.launchingFromTriggeredResult,
            ) { key, launchingResult ->
                val validKey = key != null && key == launchingResult?.configKey
                if (validKey && launchingResult?.launched == true) {
                    HapticState.LAUNCH
                } else {
                    HapticState.NO_HAPTICS
                }
            }
            .distinctUntilChanged()

    private val toggleHapticState: Flow<HapticState> =
        activatedHistory
            .map { history ->
                when {
                    history.previousValue == false && history.currentValue -> HapticState.TOGGLE_ON
                    history.previousValue == true && !history.currentValue -> HapticState.TOGGLE_OFF
                    else -> HapticState.NO_HAPTICS
                }
            }
            .distinctUntilChanged()

    val quickAffordanceHapticState =
        merge(launchingHapticState, toggleHapticState).distinctUntilChanged()

    fun resetLaunchingFromTriggeredResult() =
        quickAffordanceInteractor.setLaunchingFromTriggeredResult(null)

    fun updateActivatedHistory(isActivated: Boolean) {
        activatedHistory.value =
            ActivatedHistory(
                currentValue = isActivated,
                previousValue = activatedHistory.value.currentValue,
            )
    }

    enum class HapticState {
        TOGGLE_ON,
        TOGGLE_OFF,
        LAUNCH,
        NO_HAPTICS,
    }

    private data class ActivatedHistory(
        val currentValue: Boolean,
        val previousValue: Boolean? = null,
    )

    @AssistedFactory
    interface Factory {
        fun create(
            quickAffordanceViewModel: Flow<KeyguardQuickAffordanceViewModel>
        ): KeyguardQuickAffordanceHapticViewModel
    }
}
+35 −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.keyguard.domain.interactor

import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceHapticViewModel
import com.android.systemui.keyguard.ui.viewmodel.KeyguardQuickAffordanceViewModel
import com.android.systemui.kosmos.Kosmos
import kotlinx.coroutines.flow.Flow

val Kosmos.keyguardQuickAffordanceHapticViewModelFactory by
    Kosmos.Fixture {
        object : KeyguardQuickAffordanceHapticViewModel.Factory {
            override fun create(
                quickAffordanceViewModel: Flow<KeyguardQuickAffordanceViewModel>
            ): KeyguardQuickAffordanceHapticViewModel =
                KeyguardQuickAffordanceHapticViewModel(
                    quickAffordanceViewModel,
                    keyguardQuickAffordanceInteractor,
                )
        }
    }