Loading packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +6 −18 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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() } } Loading Loading @@ -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, ) } } Loading @@ -387,7 +375,7 @@ private suspend fun showEntryAnimation( delayMillis = 33 * dot.y, durationMillis = 450, easing = Easings.LegacyDecelerate, ) ), ) } } Loading @@ -400,7 +388,7 @@ private suspend fun showEntryAnimation( delayMillis = 0, durationMillis = 450 + (33 * dot.y), easing = Easings.StandardDecelerate, ) ), ) } } Loading packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +21 −39 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -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( Loading @@ -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( Loading @@ -138,7 +135,7 @@ fun PinPad( onClicked = viewModel::onPinButtonClicked, scaling = buttonScaleAnimatables[10]::value, isAnimationEnabled = isDigitButtonAnimationEnabled, onPointerDown = viewModel::onDigitButtonDown onPointerDown = viewModel::onDigitButtonDown, ) ActionButton( Loading @@ -152,7 +149,7 @@ fun PinPad( onClicked = viewModel::onAuthenticateButtonClicked, appearance = confirmButtonAppearance, scaling = buttonScaleAnimatables[11]::value, elementId = "key_enter" elementId = "key_enter", ) } } Loading @@ -162,7 +159,7 @@ private fun DigitButton( digit: Int, isInputEnabled: Boolean, onClicked: (Int) -> Unit, onPointerDown: () -> Unit, onPointerDown: (View?) -> Unit, scaling: () -> Float, isAnimationEnabled: Boolean, ) { Loading @@ -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. Loading @@ -197,6 +194,7 @@ private fun ActionButton( onClicked: () -> Unit, elementId: String, onLongPressed: (() -> Unit)? = null, onPointerDown: ((View?) -> Unit)? = null, appearance: ActionButtonAppearance, scaling: () -> Float, ) { Loading @@ -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()) } } Loading @@ -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 Loading @@ -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 Loading @@ -287,7 +274,7 @@ private fun PinPadButton( else -> backgroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec animationSpec = colorAnimationSpec, ) val contentColor = animateColorAsState( Loading @@ -296,7 +283,7 @@ private fun PinPadButton( else -> foregroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec animationSpec = colorAnimationSpec, ) Box( Loading @@ -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 } Loading Loading @@ -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), ) } } Loading @@ -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) } Loading packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt +50 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } } packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt +24 −11 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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") Loading Loading @@ -174,7 +180,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(0, 0), PatternDotViewModel(1, 0), PatternDotViewModel(2, 0) PatternDotViewModel(2, 0), ) ) } Loading @@ -200,7 +206,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(1, 0), PatternDotViewModel(1, 1), PatternDotViewModel(1, 2) PatternDotViewModel(1, 2), ) ) } Loading Loading @@ -228,7 +234,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(2, 0), PatternDotViewModel(1, 1), PatternDotViewModel(0, 2) PatternDotViewModel(0, 2), ) ) } Loading Loading @@ -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, Loading Loading @@ -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) Loading packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +38 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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, ) } Loading Loading @@ -97,6 +104,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { isInputEnabled = MutableStateFlow(true), onIntentionalUserInput = {}, authenticationMethod = AuthenticationMethodModel.Sim, bouncerHapticPlayer = bouncerHapticPlayer, ) assertThat(underTest.isSimAreaVisible).isTrue() Loading @@ -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) Loading Loading @@ -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 Loading
packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +6 −18 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) { Loading Loading @@ -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() } } Loading Loading @@ -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, ) } } Loading @@ -387,7 +375,7 @@ private suspend fun showEntryAnimation( delayMillis = 33 * dot.y, durationMillis = 450, easing = Easings.LegacyDecelerate, ) ), ) } } Loading @@ -400,7 +388,7 @@ private suspend fun showEntryAnimation( delayMillis = 0, durationMillis = 450 + (33 * dot.y), easing = Easings.StandardDecelerate, ) ), ) } } Loading
packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +21 −39 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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() Loading Loading @@ -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( Loading @@ -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( Loading @@ -138,7 +135,7 @@ fun PinPad( onClicked = viewModel::onPinButtonClicked, scaling = buttonScaleAnimatables[10]::value, isAnimationEnabled = isDigitButtonAnimationEnabled, onPointerDown = viewModel::onDigitButtonDown onPointerDown = viewModel::onDigitButtonDown, ) ActionButton( Loading @@ -152,7 +149,7 @@ fun PinPad( onClicked = viewModel::onAuthenticateButtonClicked, appearance = confirmButtonAppearance, scaling = buttonScaleAnimatables[11]::value, elementId = "key_enter" elementId = "key_enter", ) } } Loading @@ -162,7 +159,7 @@ private fun DigitButton( digit: Int, isInputEnabled: Boolean, onClicked: (Int) -> Unit, onPointerDown: () -> Unit, onPointerDown: (View?) -> Unit, scaling: () -> Float, isAnimationEnabled: Boolean, ) { Loading @@ -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. Loading @@ -197,6 +194,7 @@ private fun ActionButton( onClicked: () -> Unit, elementId: String, onLongPressed: (() -> Unit)? = null, onPointerDown: ((View?) -> Unit)? = null, appearance: ActionButtonAppearance, scaling: () -> Float, ) { Loading @@ -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()) } } Loading @@ -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 Loading @@ -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 Loading @@ -287,7 +274,7 @@ private fun PinPadButton( else -> backgroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec animationSpec = colorAnimationSpec, ) val contentColor = animateColorAsState( Loading @@ -296,7 +283,7 @@ private fun PinPadButton( else -> foregroundColor }, label = "Pin button container color", animationSpec = colorAnimationSpec animationSpec = colorAnimationSpec, ) Box( Loading @@ -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 } Loading Loading @@ -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), ) } } Loading @@ -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) } Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt +50 −0 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading Loading @@ -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) } }
packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt +24 −11 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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 Loading Loading @@ -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") Loading Loading @@ -174,7 +180,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(0, 0), PatternDotViewModel(1, 0), PatternDotViewModel(2, 0) PatternDotViewModel(2, 0), ) ) } Loading @@ -200,7 +206,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(1, 0), PatternDotViewModel(1, 1), PatternDotViewModel(1, 2) PatternDotViewModel(1, 2), ) ) } Loading Loading @@ -228,7 +234,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { listOf( PatternDotViewModel(2, 0), PatternDotViewModel(1, 1), PatternDotViewModel(0, 2) PatternDotViewModel(0, 2), ) ) } Loading Loading @@ -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, Loading Loading @@ -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) Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +38 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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 Loading @@ -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, ) } Loading Loading @@ -97,6 +104,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { isInputEnabled = MutableStateFlow(true), onIntentionalUserInput = {}, authenticationMethod = AuthenticationMethodModel.Sim, bouncerHapticPlayer = bouncerHapticPlayer, ) assertThat(underTest.isSimAreaVisible).isTrue() Loading @@ -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) Loading Loading @@ -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