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

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

Merge "Adding MSDL feedback to Compose Bouncers." into main

parents 9f25f92e ea44040c
Loading
Loading
Loading
Loading
+6 −18
Original line number Diff line number Diff line
@@ -16,7 +16,6 @@

package com.android.systemui.bouncer.ui.composable

import android.view.HapticFeedbackConstants
import androidx.annotation.VisibleForTesting
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
@@ -133,10 +132,7 @@ fun PatternBouncer(
        // Perform haptic feedback, but only if the current dot is not null, so we don't perform it
        // when the UI first shows up or when the user lifts their pointer/finger.
        if (currentDot != null) {
            view.performHapticFeedback(
                HapticFeedbackConstants.VIRTUAL_KEY,
                HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
            )
            viewModel.performDotFeedback(view)
        }

        if (!isAnimationEnabled) {
@@ -206,10 +202,7 @@ fun PatternBouncer(
    // Show the failure animation if the user entered the wrong input.
    LaunchedEffect(animateFailure) {
        if (animateFailure) {
            showFailureAnimation(
                dots = dots,
                scalingAnimatables = dotScalingAnimatables,
            )
            showFailureAnimation(dots = dots, scalingAnimatables = dotScalingAnimatables)
            viewModel.onFailureAnimationShown()
        }
    }
@@ -358,15 +351,10 @@ fun PatternBouncer(
                    (1 - checkNotNull(dotAppearMoveUpAnimatables[dot]).value) * initialOffset
                drawCircle(
                    center =
                        pixelOffset(
                            dot,
                            spacing,
                            horizontalOffset,
                            verticalOffset + appearOffset,
                        ),
                        pixelOffset(dot, spacing, horizontalOffset, verticalOffset + appearOffset),
                    color =
                        dotColor.copy(alpha = checkNotNull(dotAppearFadeInAnimatables[dot]).value),
                    radius = dotRadius * checkNotNull(dotScalingAnimatables[dot]).value
                    radius = dotRadius * checkNotNull(dotScalingAnimatables[dot]).value,
                )
            }
        }
@@ -387,7 +375,7 @@ private suspend fun showEntryAnimation(
                            delayMillis = 33 * dot.y,
                            durationMillis = 450,
                            easing = Easings.LegacyDecelerate,
                        )
                        ),
                )
            }
        }
@@ -400,7 +388,7 @@ private suspend fun showEntryAnimation(
                            delayMillis = 0,
                            durationMillis = 450 + (33 * dot.y),
                            easing = Easings.StandardDecelerate,
                        )
                        ),
                )
            }
        }
+21 −39
Original line number Diff line number Diff line
@@ -16,8 +16,8 @@

package com.android.systemui.bouncer.ui.composable

import android.view.HapticFeedbackConstants
import android.view.MotionEvent
import android.view.View
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
@@ -72,11 +72,7 @@ import kotlinx.coroutines.launch

/** Renders the PIN button pad. */
@Composable
fun PinPad(
    viewModel: PinBouncerViewModel,
    verticalSpacing: Dp,
    modifier: Modifier = Modifier,
) {
fun PinPad(viewModel: PinBouncerViewModel, verticalSpacing: Dp, modifier: Modifier = Modifier) {
    DisposableEffect(Unit) { onDispose { viewModel.onHidden() } }

    val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsStateWithLifecycle()
@@ -104,7 +100,7 @@ fun PinPad(
        columns = columns,
        verticalSpacing = verticalSpacing,
        horizontalSpacing = calculateHorizontalSpacingBetweenColumns(gridWidth = 300.dp),
        modifier = modifier.focusRequester(focusRequester).sysuiResTag("pin_pad_grid")
        modifier = modifier.focusRequester(focusRequester).sysuiResTag("pin_pad_grid"),
    ) {
        repeat(9) { index ->
            DigitButton(
@@ -126,10 +122,11 @@ fun PinPad(
                ),
            isInputEnabled = isInputEnabled,
            onClicked = viewModel::onBackspaceButtonClicked,
            onPointerDown = viewModel::onBackspaceButtonPressed,
            onLongPressed = viewModel::onBackspaceButtonLongPressed,
            appearance = backspaceButtonAppearance,
            scaling = buttonScaleAnimatables[9]::value,
            elementId = "delete_button"
            elementId = "delete_button",
        )

        DigitButton(
@@ -138,7 +135,7 @@ fun PinPad(
            onClicked = viewModel::onPinButtonClicked,
            scaling = buttonScaleAnimatables[10]::value,
            isAnimationEnabled = isDigitButtonAnimationEnabled,
            onPointerDown = viewModel::onDigitButtonDown
            onPointerDown = viewModel::onDigitButtonDown,
        )

        ActionButton(
@@ -152,7 +149,7 @@ fun PinPad(
            onClicked = viewModel::onAuthenticateButtonClicked,
            appearance = confirmButtonAppearance,
            scaling = buttonScaleAnimatables[11]::value,
            elementId = "key_enter"
            elementId = "key_enter",
        )
    }
}
@@ -162,7 +159,7 @@ private fun DigitButton(
    digit: Int,
    isInputEnabled: Boolean,
    onClicked: (Int) -> Unit,
    onPointerDown: () -> Unit,
    onPointerDown: (View?) -> Unit,
    scaling: () -> Float,
    isAnimationEnabled: Boolean,
) {
@@ -178,7 +175,7 @@ private fun DigitButton(
                val scale = if (isAnimationEnabled) scaling() else 1f
                scaleX = scale
                scaleY = scale
            }
            },
    ) { contentColor ->
        // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes
        // it into Text, use that here, to animate more efficiently.
@@ -197,6 +194,7 @@ private fun ActionButton(
    onClicked: () -> Unit,
    elementId: String,
    onLongPressed: (() -> Unit)? = null,
    onPointerDown: ((View?) -> Unit)? = null,
    appearance: ActionButtonAppearance,
    scaling: () -> Float,
) {
@@ -222,18 +220,16 @@ private fun ActionButton(
        foregroundColor = foregroundColor,
        isAnimationEnabled = true,
        elementId = elementId,
        onPointerDown = onPointerDown,
        modifier =
            Modifier.graphicsLayer {
                alpha = hiddenAlpha
                val scale = scaling()
                scaleX = scale
                scaleY = scale
            }
            },
    ) { contentColor ->
        Icon(
            icon = icon,
            tint = contentColor(),
        )
        Icon(icon = icon, tint = contentColor())
    }
}

@@ -247,22 +243,13 @@ private fun PinPadButton(
    modifier: Modifier = Modifier,
    elementId: String? = null,
    onLongPressed: (() -> Unit)? = null,
    onPointerDown: (() -> Unit)? = null,
    onPointerDown: ((View?) -> Unit)? = null,
    content: @Composable (contentColor: () -> Color) -> Unit,
) {
    val interactionSource = remember { MutableInteractionSource() }
    val isPressed by interactionSource.collectIsPressedAsState()
    val indication = LocalIndication.current.takeUnless { isPressed }

    val view = LocalView.current
    LaunchedEffect(isPressed) {
        if (isPressed) {
            view.performHapticFeedback(
                HapticFeedbackConstants.VIRTUAL_KEY,
                HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
            )
        }
    }

    // Pin button animation specification is asymmetric: fast animation to the pressed state, and a
    // slow animation upon release. Note that isPressed is guaranteed to be true for at least the
@@ -277,7 +264,7 @@ private fun PinPadButton(
        animateDpAsState(
            if (isAnimationEnabled && isPressed) 24.dp else pinButtonMaxSize / 2,
            label = "PinButton round corners",
            animationSpec = tween(animDurationMillis, easing = animEasing)
            animationSpec = tween(animDurationMillis, easing = animEasing),
        )
    val colorAnimationSpec: AnimationSpec<Color> = tween(animDurationMillis, easing = animEasing)
    val containerColor: Color by
@@ -287,7 +274,7 @@ private fun PinPadButton(
                else -> backgroundColor
            },
            label = "Pin button container color",
            animationSpec = colorAnimationSpec
            animationSpec = colorAnimationSpec,
        )
    val contentColor =
        animateColorAsState(
@@ -296,7 +283,7 @@ private fun PinPadButton(
                else -> foregroundColor
            },
            label = "Pin button container color",
            animationSpec = colorAnimationSpec
            animationSpec = colorAnimationSpec,
        )

    Box(
@@ -319,11 +306,11 @@ private fun PinPadButton(
                            interactionSource = interactionSource,
                            indication = indication,
                            onClick = onClicked,
                            onLongClick = onLongPressed
                            onLongClick = onLongPressed,
                        )
                        .pointerInteropFilter { motionEvent ->
                            if (motionEvent.action == MotionEvent.ACTION_DOWN) {
                                onPointerDown?.let { it() }
                                onPointerDown?.let { it(view) }
                            }
                            false
                        }
@@ -353,10 +340,7 @@ private suspend fun showFailureAnimation(
                animatable.animateTo(
                    targetValue = 1f,
                    animationSpec =
                        tween(
                            durationMillis = pinButtonErrorRevertMs,
                            easing = Easings.Legacy,
                        ),
                        tween(durationMillis = pinButtonErrorRevertMs, easing = Easings.Legacy),
                )
            }
        }
@@ -364,9 +348,7 @@ private suspend fun showFailureAnimation(
}

/** Returns the amount of horizontal spacing between columns, in dips. */
private fun calculateHorizontalSpacingBetweenColumns(
    gridWidth: Dp,
): Dp {
private fun calculateHorizontalSpacingBetweenColumns(gridWidth: Dp): Dp {
    return (gridWidth - (pinButtonMaxSize * columns)) / (columns - 1)
}

+50 −0
Original line number Diff line number Diff line
@@ -16,18 +16,26 @@

package com.android.systemui.bouncer.ui.viewmodel

import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.keyguard.AuthInteractionProperties
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.msdl.bouncerHapticPlayer
import com.android.systemui.haptics.msdl.fakeMSDLPlayer
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.testKosmos
import com.google.android.msdl.data.model.MSDLToken
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@@ -39,11 +47,15 @@ class AuthMethodBouncerViewModelTest : SysuiTestCase() {

    private val kosmos = testKosmos()
    private val testScope = kosmos.testScope
    private val msdlPlayer = kosmos.fakeMSDLPlayer
    private val bouncerHapticPlayer = kosmos.bouncerHapticPlayer
    private val authInteractionProperties = AuthInteractionProperties()
    private val underTest =
        kosmos.pinBouncerViewModelFactory.create(
            isInputEnabled = MutableStateFlow(true),
            onIntentionalUserInput = {},
            authenticationMethod = AuthenticationMethodModel.Pin,
            bouncerHapticPlayer = bouncerHapticPlayer,
        )

    @Before
@@ -77,4 +89,42 @@ class AuthMethodBouncerViewModelTest : SysuiTestCase() {
            underTest.onAuthenticateButtonClicked()
            assertThat(animateFailure).isFalse()
        }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun onAuthenticationResult_playUnlockTokenIfSuccessful() =
        testScope.runTest {
            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                AuthenticationMethodModel.Pin
            )
            // Correct PIN:
            FakeAuthenticationRepository.DEFAULT_PIN.forEach { digit ->
                underTest.onPinButtonClicked(digit)
            }
            underTest.onAuthenticateButtonClicked()
            runCurrent()

            assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.UNLOCK)
            assertThat(msdlPlayer.latestPropertiesPlayed).isEqualTo(authInteractionProperties)
        }

    @OptIn(ExperimentalCoroutinesApi::class)
    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun onAuthenticationResult_playFailureTokenIfFailure() =
        testScope.runTest {
            kosmos.fakeAuthenticationRepository.setAuthenticationMethod(
                AuthenticationMethodModel.Pin
            )
            // Wrong PIN:
            FakeAuthenticationRepository.DEFAULT_PIN.drop(2).forEach { digit ->
                underTest.onPinButtonClicked(digit)
            }
            underTest.onAuthenticateButtonClicked()
            runCurrent()

            assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.FAILURE)
            assertThat(msdlPlayer.latestPropertiesPlayed).isEqualTo(authInteractionProperties)
        }
}
+24 −11
Original line number Diff line number Diff line
@@ -16,9 +16,11 @@

package com.android.systemui.bouncer.ui.viewmodel

import android.platform.test.annotations.EnableFlags
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.SceneKey
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.data.repository.authenticationRepository
@@ -27,12 +29,16 @@ import com.android.systemui.authentication.domain.interactor.authenticationInter
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.authentication.shared.model.AuthenticationPatternCoordinate as Point
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.msdl.FakeMSDLPlayer
import com.android.systemui.haptics.msdl.bouncerHapticPlayer
import com.android.systemui.haptics.msdl.fakeMSDLPlayer
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.res.R
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.testKosmos
import com.google.android.msdl.data.model.MSDLToken
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -55,10 +61,13 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
    private val authenticationInteractor by lazy { kosmos.authenticationInteractor }
    private val sceneInteractor by lazy { kosmos.sceneInteractor }
    private val bouncerViewModel by lazy { kosmos.bouncerSceneContentViewModel }
    private val msdlPlayer: FakeMSDLPlayer = kosmos.fakeMSDLPlayer
    private val bouncerHapticHelper = kosmos.bouncerHapticPlayer
    private val underTest =
        kosmos.patternBouncerViewModelFactory.create(
            isInputEnabled = MutableStateFlow(true).asStateFlow(),
            onIntentionalUserInput = {},
            bouncerHapticPlayer = bouncerHapticHelper,
        )

    private val containerSize = 90 // px
@@ -115,10 +124,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
                    .that(selectedDots)
                    .isEqualTo(
                        CORRECT_PATTERN.subList(0, index + 1).map {
                            PatternDotViewModel(
                                x = it.x,
                                y = it.y,
                            )
                            PatternDotViewModel(x = it.x, y = it.y)
                        }
                    )
                assertWithMessage("Wrong current dot for index $index")
@@ -174,7 +180,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
                    listOf(
                        PatternDotViewModel(0, 0),
                        PatternDotViewModel(1, 0),
                        PatternDotViewModel(2, 0)
                        PatternDotViewModel(2, 0),
                    )
                )
        }
@@ -200,7 +206,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
                    listOf(
                        PatternDotViewModel(1, 0),
                        PatternDotViewModel(1, 1),
                        PatternDotViewModel(1, 2)
                        PatternDotViewModel(1, 2),
                    )
                )
        }
@@ -228,7 +234,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
                    listOf(
                        PatternDotViewModel(2, 0),
                        PatternDotViewModel(1, 1),
                        PatternDotViewModel(0, 2)
                        PatternDotViewModel(0, 2),
                    )
                )
        }
@@ -300,10 +306,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
            val attempts = FakeAuthenticationRepository.MAX_FAILED_AUTH_TRIES_BEFORE_LOCKOUT + 1
            repeat(attempts) { attempt ->
                underTest.onDragStart()
                CORRECT_PATTERN.subList(
                        0,
                        kosmos.authenticationRepository.minPatternLength - 1,
                    )
                CORRECT_PATTERN.subList(0, kosmos.authenticationRepository.minPatternLength - 1)
                    .forEach { coordinate ->
                        underTest.onDrag(
                            xPx = 30f * coordinate.x + 15,
@@ -341,6 +344,16 @@ class PatternBouncerViewModelTest : SysuiTestCase() {
            assertThat(authResult).isTrue()
        }

    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun performDotFeedback_deliversDragToken() =
        testScope.runTest {
            underTest.performDotFeedback(null)

            assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.DRAG_INDICATOR)
            assertThat(msdlPlayer.latestPropertiesPlayed).isNull()
        }

    private fun dragOverCoordinates(vararg coordinatesDragged: Point) {
        underTest.onDragStart()
        coordinatesDragged.forEach(::dragToCoordinate)
+38 −1
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.compose.animation.scene.SceneKey
import com.android.systemui.Flags
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.data.repository.FakeAuthenticationRepository
import com.android.systemui.authentication.data.repository.fakeAuthenticationRepository
@@ -35,12 +36,15 @@ import com.android.systemui.authentication.shared.model.AuthenticationMethodMode
import com.android.systemui.bouncer.data.repository.fakeSimBouncerRepository
import com.android.systemui.classifier.fakeFalsingCollector
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.haptics.msdl.bouncerHapticPlayer
import com.android.systemui.haptics.msdl.fakeMSDLPlayer
import com.android.systemui.kosmos.testScope
import com.android.systemui.lifecycle.activateIn
import com.android.systemui.res.R
import com.android.systemui.scene.domain.interactor.sceneInteractor
import com.android.systemui.scene.shared.model.Scenes
import com.android.systemui.testKosmos
import com.google.android.msdl.data.model.MSDLToken
import com.google.common.truth.Truth.assertThat
import kotlin.random.Random
import kotlin.random.nextInt
@@ -64,11 +68,14 @@ class PinBouncerViewModelTest : SysuiTestCase() {
    private val testScope = kosmos.testScope
    private val sceneInteractor by lazy { kosmos.sceneInteractor }
    private val authenticationInteractor by lazy { kosmos.authenticationInteractor }
    private val msdlPlayer = kosmos.fakeMSDLPlayer
    private val bouncerHapticPlayer = kosmos.bouncerHapticPlayer
    private val underTest by lazy {
        kosmos.pinBouncerViewModelFactory.create(
            isInputEnabled = MutableStateFlow(true),
            onIntentionalUserInput = {},
            authenticationMethod = AuthenticationMethodModel.Pin,
            bouncerHapticPlayer = bouncerHapticPlayer,
        )
    }

@@ -97,6 +104,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
                    isInputEnabled = MutableStateFlow(true),
                    onIntentionalUserInput = {},
                    authenticationMethod = AuthenticationMethodModel.Sim,
                    bouncerHapticPlayer = bouncerHapticPlayer,
                )

            assertThat(underTest.isSimAreaVisible).isTrue()
@@ -122,6 +130,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
                    isInputEnabled = MutableStateFlow(true),
                    onIntentionalUserInput = {},
                    authenticationMethod = AuthenticationMethodModel.Pin,
                    bouncerHapticPlayer = bouncerHapticPlayer,
                )
            kosmos.fakeAuthenticationRepository.setAutoConfirmFeatureEnabled(true)
            val hintedPinLength by collectLastValue(underTest.hintedPinLength)
@@ -487,11 +496,39 @@ class PinBouncerViewModelTest : SysuiTestCase() {
        testScope.runTest {
            lockDeviceAndOpenPinBouncer()

            underTest.onDigitButtonDown()
            underTest.onDigitButtonDown(null)

            assertTrue(kosmos.fakeFalsingCollector.wasLastGestureAvoided())
        }

    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun onDigiButtonDown_deliversKeyStandardToken() =
        testScope.runTest {
            underTest.onDigitButtonDown(null)

            assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.KEYPRESS_STANDARD)
            assertThat(msdlPlayer.latestPropertiesPlayed).isNull()
        }

    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun onBackspaceButtonPressed_deliversKeyDeleteToken() {
        underTest.onBackspaceButtonPressed(null)

        assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.KEYPRESS_DELETE)
        assertThat(msdlPlayer.latestPropertiesPlayed).isNull()
    }

    @Test
    @EnableFlags(Flags.FLAG_MSDL_FEEDBACK)
    fun onBackspaceButtonLongPressed_deliversLongPressToken() {
        underTest.onBackspaceButtonLongPressed()

        assertThat(msdlPlayer.latestTokenPlayed).isEqualTo(MSDLToken.LONG_PRESS)
        assertThat(msdlPlayer.latestPropertiesPlayed).isNull()
    }

    private fun TestScope.switchToScene(toScene: SceneKey) {
        val currentScene by collectLastValue(sceneInteractor.currentScene)
        val bouncerHidden = currentScene == Scenes.Bouncer && toScene != Scenes.Bouncer
Loading