Merge changes from topic "bouncer-scene-throttling-b280877228" into udc-dev am: 637fb7497b

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/23353743

Change-Id: I09e71351c525641d8e98d5b9c4fb9822c402504d
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Ale Nijamkin
2023-05-26 00:47:34 +00:00
committed by Automerger Merge Worker
10 changed files with 241 additions and 24 deletions

View File

@@ -54,6 +54,7 @@ internal fun PasswordBouncer(
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val password: String by viewModel.password.collectAsState() val password: String by viewModel.password.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// When the UI comes up, request focus on the TextField to bring up the software keyboard. // When the UI comes up, request focus on the TextField to bring up the software keyboard.
@@ -62,6 +63,13 @@ internal fun PasswordBouncer(
viewModel.onShown() viewModel.onShown()
} }
LaunchedEffect(animateFailure) {
if (animateFailure) {
// We don't currently have a failure animation for password, just consume it:
viewModel.onFailureAnimationShown()
}
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier, modifier = modifier,

View File

@@ -18,6 +18,7 @@ package com.android.systemui.bouncer.ui.composable
import android.view.HapticFeedbackConstants import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.gestures.detectDragGestures
@@ -49,6 +50,7 @@ import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.min import kotlin.math.min
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sqrt import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -86,6 +88,7 @@ internal fun PatternBouncer(
val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState() val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState() val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Map of animatables for the scale of each dot, keyed by dot. // Map of animatables for the scale of each dot, keyed by dot.
val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } } val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } }
@@ -174,6 +177,17 @@ internal fun PatternBouncer(
} }
} }
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation(
dots = dots,
scalingAnimatables = dotScalingAnimatables,
)
viewModel.onFailureAnimationShown()
}
}
// This is the position of the input pointer. // This is the position of the input pointer.
var inputPosition: Offset? by remember { mutableStateOf(null) } var inputPosition: Offset? by remember { mutableStateOf(null) }
@@ -290,8 +304,61 @@ private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Floa
return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f) return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f)
} }
private suspend fun showFailureAnimation(
dots: List<PatternDotViewModel>,
scalingAnimatables: Map<PatternDotViewModel, Animatable<Float, AnimationVector1D>>,
) {
val dotsByRow =
buildList<MutableList<PatternDotViewModel>> {
dots.forEach { dot ->
val rowIndex = dot.y
while (size <= rowIndex) {
add(mutableListOf())
}
get(rowIndex).add(dot)
}
}
coroutineScope {
dotsByRow.forEachIndexed { rowIndex, rowDots ->
rowDots.forEach { dot ->
scalingAnimatables[dot]?.let { dotScaleAnimatable ->
launch {
dotScaleAnimatable.animateTo(
targetValue =
FAILURE_ANIMATION_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat(),
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS,
delayMillis =
rowIndex * FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS,
easing = Easings.LinearEasing,
),
)
dotScaleAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis =
FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION,
easing = Easings.StandardEasing,
),
)
}
}
}
}
}
}
private const val DOT_DIAMETER_DP = 16 private const val DOT_DIAMETER_DP = 16
private const val SELECTED_DOT_DIAMETER_DP = 24 private const val SELECTED_DOT_DIAMETER_DP = 24
private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83 private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83
private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750 private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750
private const val LINE_STROKE_WIDTH_DP = 16 private const val LINE_STROKE_WIDTH_DP = 16
private const val FAILURE_ANIMATION_DOT_DIAMETER_DP = 13
private const val FAILURE_ANIMATION_DOT_SHRINK_ANIMATION_DURATION_MS = 50
private const val FAILURE_ANIMATION_DOT_SHRINK_STAGGER_DELAY_MS = 33
private const val FAILURE_ANIMATION_DOT_REVERT_ANIMATION_DURATION = 617

View File

@@ -89,6 +89,15 @@ internal fun PinBouncer(
// The length of the PIN input received so far, so we know how many dots to render. // The length of the PIN input received so far, so we know how many dots to render.
val pinLength: Pair<Int, Int> by viewModel.pinLengths.collectAsState() val pinLength: Pair<Int, Int> by viewModel.pinLengths.collectAsState()
val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
val animateFailure: Boolean by viewModel.animateFailure.collectAsState()
// Show the failure animation if the user entered the wrong input.
LaunchedEffect(animateFailure) {
if (animateFailure) {
showFailureAnimation()
viewModel.onFailureAnimationShown()
}
}
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -148,12 +148,17 @@ constructor(
* *
* If the input is correct, the device will be unlocked and the lock screen and bouncer will be * If the input is correct, the device will be unlocked and the lock screen and bouncer will be
* dismissed and hidden. * dismissed and hidden.
*
* @param input The input from the user to try to authenticate with. This can be a list of
* different things, based on the current authentication method.
* @return `true` if the authentication succeeded and the device is now unlocked; `false`
* otherwise.
*/ */
fun authenticate( fun authenticate(
input: List<Any>, input: List<Any>,
) { ): Boolean {
if (repository.throttling.value != null) { if (repository.throttling.value != null) {
return return false
} }
val isAuthenticated = authenticationInteractor.authenticate(input) val isAuthenticated = authenticationInteractor.authenticate(input)
@@ -186,6 +191,8 @@ constructor(
} }
else -> repository.setMessage(errorMessage(authenticationMethod.value)) else -> repository.setMessage(errorMessage(authenticationMethod.value))
} }
return isAuthenticated
} }
private fun promptMessage(authMethod: AuthenticationMethodModel): String { private fun promptMessage(authMethod: AuthenticationMethodModel): String {

View File

@@ -16,14 +16,37 @@
package com.android.systemui.bouncer.ui.viewmodel package com.android.systemui.bouncer.ui.viewmodel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface AuthMethodBouncerViewModel { sealed class AuthMethodBouncerViewModel(
/** /**
* Whether user input is enabled. * Whether user input is enabled.
* *
* If `false`, user input should be completely ignored in the UI as the user is "locked out" of * If `false`, user input should be completely ignored in the UI as the user is "locked out" of
* being able to attempt to unlock the device. * being able to attempt to unlock the device.
*/ */
val isInputEnabled: StateFlow<Boolean> val isInputEnabled: StateFlow<Boolean>,
) {
private val _animateFailure = MutableStateFlow(false)
/**
* Whether a failure animation should be shown. Once consumed, the UI must call
* [onFailureAnimationShown] to consume this state.
*/
val animateFailure: StateFlow<Boolean> = _animateFailure.asStateFlow()
/**
* Notifies that the failure animation has been shown. This should be called to consume a `true`
* value in [animateFailure].
*/
fun onFailureAnimationShown() {
_animateFailure.value = false
}
/** Ask the UI to show the failure animation. */
protected fun showFailureAnimation() {
_animateFailure.value = true
}
} }

View File

@@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.asStateFlow
/** Holds UI state and handles user input for the password bouncer UI. */ /** Holds UI state and handles user input for the password bouncer UI. */
class PasswordBouncerViewModel( class PasswordBouncerViewModel(
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>, isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val _password = MutableStateFlow("") private val _password = MutableStateFlow("")
/** The password entered so far. */ /** The password entered so far. */
@@ -47,7 +50,10 @@ class PasswordBouncerViewModel(
/** Notifies that the user has pressed the key for attempting to authenticate the password. */ /** Notifies that the user has pressed the key for attempting to authenticate the password. */
fun onAuthenticateKeyPressed() { fun onAuthenticateKeyPressed() {
interactor.authenticate(password.value.toCharArray().toList()) if (!interactor.authenticate(password.value.toCharArray().toList())) {
showFailureAnimation()
}
_password.value = "" _password.value = ""
} }
} }

View File

@@ -37,8 +37,11 @@ class PatternBouncerViewModel(
private val applicationContext: Context, private val applicationContext: Context,
applicationScope: CoroutineScope, applicationScope: CoroutineScope,
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>, isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
/** The number of columns in the dot grid. */ /** The number of columns in the dot grid. */
val columnCount = 3 val columnCount = 3
@@ -150,7 +153,11 @@ class PatternBouncerViewModel(
/** Notifies that the user has ended the drag gesture across the dot grid. */ /** Notifies that the user has ended the drag gesture across the dot grid. */
fun onDragEnd() { fun onDragEnd() {
val isSuccessfullyAuthenticated =
interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) interactor.authenticate(_selectedDots.value.map { it.toCoordinate() })
if (!isSuccessfullyAuthenticated) {
showFailureAnimation()
}
_dots.value = defaultDots() _dots.value = defaultDots()
_currentDot.value = null _currentDot.value = null

View File

@@ -33,8 +33,11 @@ import kotlinx.coroutines.launch
class PinBouncerViewModel( class PinBouncerViewModel(
private val applicationScope: CoroutineScope, private val applicationScope: CoroutineScope,
private val interactor: BouncerInteractor, private val interactor: BouncerInteractor,
override val isInputEnabled: StateFlow<Boolean>, isInputEnabled: StateFlow<Boolean>,
) : AuthMethodBouncerViewModel { ) :
AuthMethodBouncerViewModel(
isInputEnabled = isInputEnabled,
) {
private val entered = MutableStateFlow<List<Int>>(emptyList()) private val entered = MutableStateFlow<List<Int>>(emptyList())
/** /**
@@ -92,7 +95,10 @@ class PinBouncerViewModel(
/** Notifies that the user clicked the "enter" button. */ /** Notifies that the user clicked the "enter" button. */
fun onAuthenticateButtonClicked() { fun onAuthenticateButtonClicked() {
interactor.authenticate(entered.value) if (!interactor.authenticate(entered.value)) {
showFailureAnimation()
}
entered.value = emptyList() entered.value = emptyList()
} }

View File

@@ -82,7 +82,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Wrong input. // Wrong input.
underTest.authenticate(listOf(9, 8, 7)) assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -90,7 +90,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN)
// Correct input. // Correct input.
underTest.authenticate(listOf(1, 2, 3, 4)) assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -114,7 +114,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Wrong input. // Wrong input.
underTest.authenticate("alohamora".toList()) assertThat(underTest.authenticate("alohamora".toList())).isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD) assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -122,7 +122,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD)
// Correct input. // Correct input.
underTest.authenticate("password".toList()) assertThat(underTest.authenticate("password".toList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -146,9 +146,12 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Wrong input. // Wrong input.
assertThat(
underTest.authenticate( underTest.authenticate(
listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4)) listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4))
) )
)
.isFalse()
assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN) assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN)
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -156,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN)
// Correct input. // Correct input.
underTest.authenticate(emptyList()) assertThat(underTest.authenticate(emptyList())).isTrue()
assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone))
} }
@@ -214,7 +217,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
repeat(BouncerInteractor.THROTTLE_EVERY) { times -> repeat(BouncerInteractor.THROTTLE_EVERY) { times ->
// Wrong PIN. // Wrong PIN.
underTest.authenticate(listOf(6, 7, 8, 9)) assertThat(underTest.authenticate(listOf(6, 7, 8, 9))).isFalse()
if (times < BouncerInteractor.THROTTLE_EVERY - 1) { if (times < BouncerInteractor.THROTTLE_EVERY - 1) {
assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) assertThat(message).isEqualTo(MESSAGE_WRONG_PIN)
} }
@@ -223,7 +226,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
// Correct PIN, but throttled, so doesn't unlock: // Correct PIN, but throttled, so doesn't unlock:
underTest.authenticate(listOf(1, 2, 3, 4)) assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isFalse()
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC)
@@ -241,7 +244,7 @@ class BouncerInteractorTest : SysuiTestCase() {
assertThat(isUnlocked).isFalse() assertThat(isUnlocked).isFalse()
// Correct PIN and no longer throttled so unlocks: // Correct PIN and no longer throttled so unlocks:
underTest.authenticate(listOf(1, 2, 3, 4)) assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue()
assertThat(isUnlocked).isTrue() assertThat(isUnlocked).isTrue()
} }

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2023 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.bouncer.ui.viewmodel
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.authentication.shared.model.AuthenticationMethodModel
import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils
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.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@OptIn(ExperimentalCoroutinesApi::class)
@SmallTest
@RunWith(JUnit4::class)
class AuthMethodBouncerViewModelTest : SysuiTestCase() {
private val testScope = TestScope()
private val utils = SceneTestUtils(this, testScope)
private val authenticationInteractor =
utils.authenticationInteractor(
utils.authenticationRepository(),
)
private val underTest =
PinBouncerViewModel(
applicationScope = testScope.backgroundScope,
interactor =
utils.bouncerInteractor(
authenticationInteractor = authenticationInteractor,
sceneInteractor = utils.sceneInteractor(),
),
isInputEnabled = MutableStateFlow(true),
)
@Test
fun animateFailure() =
testScope.runTest {
authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
val animateFailure by collectLastValue(underTest.animateFailure)
assertThat(animateFailure).isFalse()
// Wrong PIN:
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onPinButtonClicked(5)
underTest.onPinButtonClicked(6)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isTrue()
underTest.onFailureAnimationShown()
assertThat(animateFailure).isFalse()
// Correct PIN:
underTest.onPinButtonClicked(1)
underTest.onPinButtonClicked(2)
underTest.onPinButtonClicked(3)
underTest.onPinButtonClicked(4)
underTest.onAuthenticateButtonClicked()
assertThat(animateFailure).isFalse()
}
}