From 4b5abe26d498b2272cc47a8001f5ea60ea1f1059 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Mon, 22 May 2023 13:39:55 -0700 Subject: [PATCH 1/6] thenIf modifier. This modifier is more ergonomic than then "then" modifier. Bug: 281871687 Test: as part of later CLs in this chain Change-Id: Ifef4f85859720f9089f7d4e14048d9bb5c5aa58d --- .../compose/modifiers/ConditionalModifiers.kt | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt b/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt new file mode 100644 index 0000000000000..83071d78c64d4 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/compose/modifiers/ConditionalModifiers.kt @@ -0,0 +1,52 @@ +/* + * 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.compose.modifiers + +import androidx.compose.ui.Modifier + +/** + * Concatenates this modifier with another if `condition` is true. + * + * @param condition Whether or not to apply the modifiers. + * @param factory Creates the modifier to concatenate with the current one. + * @return a Modifier representing this modifier followed by other in sequence. + * @see Modifier.then + * + * This method allows inline conditional addition of modifiers to a modifier chain. Instead of + * writing + * + * ``` + * val aModifier = Modifier.a() + * val bModifier = if(condition) aModifier.b() else aModifier + * Composable(modifier = bModifier) + * ``` + * + * You can instead write + * + * ``` + * Composable(modifier = Modifier.a().thenIf(condition){ + * Modifier.b() + * } + * ``` + * + * This makes the modifier chain easier to read. + * + * Note that unlike the non-factory version, the conditional modifier is recreated each time, and + * may never be created at all. + */ +inline fun Modifier.thenIf(condition: Boolean, crossinline factory: () -> Modifier): Modifier = + if (condition) this.then(factory()) else this From 33ddd9ab1789643fc9696f109987faa24e6f3774 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Wed, 17 May 2023 16:02:01 -0700 Subject: [PATCH 2/6] [flexiglass] Bouncer throttling - composables. Compose rendering logic for throttling input entry on the bouncer when the user enters wrong input too many times. Bug: 280877228 Test: unit tests Test: manually verified in PIN, pattern, and password that entering the wrong input 5, 10, 15, or any number above 15, times shows the throttling dialog. Test: manually verified that the throttling dialog cannot be dismissed without touching its "Ok" button (tapping outside or hitting back don't dismiss it) Test: manually verified that input on the bouncer is disabled as the message is showing the countdown for 30 seconds. Test: manually verified that after the 30 second countdown, the input is enabled again and entering the correct input unlocks Flexiglass. Change-Id: I7f5b8b7e572c4fe00f3315ccb957f036ed9e8d29 --- .../scene/ui/composable/SceneModule.kt | 4 + .../bouncer/ui/composable/BouncerScene.kt | 46 ++++++++++- .../bouncer/ui/composable/PasswordBouncer.kt | 2 + .../bouncer/ui/composable/PatternBouncer.kt | 78 ++++++++++++------- .../bouncer/ui/composable/PinBouncer.kt | 29 ++++--- .../bouncer/ui/viewmodel/BouncerViewModel.kt | 55 ++++++++----- .../ui/viewmodel/BouncerViewModelTest.kt | 11 ++- .../viewmodel/PasswordBouncerViewModelTest.kt | 10 +-- .../viewmodel/PatternBouncerViewModelTest.kt | 8 +- .../ui/viewmodel/PinBouncerViewModelTest.kt | 14 ++-- 10 files changed, 174 insertions(+), 83 deletions(-) diff --git a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/scene/ui/composable/SceneModule.kt b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/scene/ui/composable/SceneModule.kt index 954bad56bcc29..d3643747ad918 100644 --- a/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/scene/ui/composable/SceneModule.kt +++ b/packages/SystemUI/compose/facade/enabled/src/com/android/systemui/scene/ui/composable/SceneModule.kt @@ -16,6 +16,7 @@ package com.android.systemui.scene.ui.composable +import android.content.Context import com.android.systemui.bouncer.ui.composable.BouncerScene import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.dagger.SysUISingleton @@ -28,6 +29,7 @@ import com.android.systemui.scene.shared.model.Scene import com.android.systemui.scene.shared.model.SceneContainerNames import com.android.systemui.shade.ui.composable.ShadeScene import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel +import com.android.systemui.statusbar.phone.SystemUIDialog import dagger.Module import dagger.Provides import javax.inject.Named @@ -57,6 +59,7 @@ object SceneModule { @SysUISingleton @Named(SceneContainerNames.SYSTEM_UI_DEFAULT) fun bouncerScene( + @Application context: Context, viewModelFactory: BouncerViewModel.Factory, ): BouncerScene { return BouncerScene( @@ -64,6 +67,7 @@ object SceneModule { viewModelFactory.create( containerName = SceneContainerNames.SYSTEM_UI_DEFAULT, ), + dialogFactory = { SystemUIDialog(context) }, ) } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt index 3c74ef5adfeb9..240bace21a1ce 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt @@ -14,9 +14,16 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3Api::class) + package com.android.systemui.bouncer.ui.composable +import android.app.AlertDialog +import android.app.Dialog +import android.content.DialogInterface import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -26,15 +33,20 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.android.systemui.R import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel @@ -51,6 +63,7 @@ import kotlinx.coroutines.flow.asStateFlow /** The bouncer scene displays authentication challenges like PIN, password, or pattern. */ class BouncerScene( private val viewModel: BouncerViewModel, + private val dialogFactory: () -> AlertDialog, ) : ComposableScene { override val key = SceneKey.Bouncer @@ -68,16 +81,19 @@ class BouncerScene( override fun Content( containerName: String, modifier: Modifier, - ) = BouncerScene(viewModel, modifier) + ) = BouncerScene(viewModel, dialogFactory, modifier) } @Composable private fun BouncerScene( viewModel: BouncerViewModel, + dialogFactory: () -> AlertDialog, modifier: Modifier = Modifier, ) { - val message: String by viewModel.message.collectAsState() + val message: BouncerViewModel.MessageViewModel by viewModel.message.collectAsState() val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState() + val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState() + var dialog: Dialog? by remember { mutableStateOf(null) } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -88,9 +104,10 @@ private fun BouncerScene( Crossfade( targetState = message, label = "Bouncer message", - ) { + animationSpec = if (message.isUpdateAnimated) tween() else snap(), + ) { message -> Text( - text = it, + text = message.text, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge, ) @@ -132,5 +149,26 @@ private fun BouncerScene( style = MaterialTheme.typography.bodyMedium, ) } + + if (dialogMessage != null) { + if (dialog == null) { + dialog = + dialogFactory().apply { + setMessage(dialogMessage) + setButton( + DialogInterface.BUTTON_NEUTRAL, + context.getString(R.string.ok), + ) { _, _ -> + viewModel.onThrottlingDialogDismissed() + } + setCancelable(false) + setCanceledOnTouchOutside(false) + show() + } + } + } else { + dialog?.dismiss() + dialog = null + } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt index 4e85621e9e233..01346c7170dae 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -53,6 +53,7 @@ internal fun PasswordBouncer( ) { val focusRequester = remember { FocusRequester() } val password: String by viewModel.password.collectAsState() + val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() LaunchedEffect(Unit) { // When the UI comes up, request focus on the TextField to bring up the software keyboard. @@ -71,6 +72,7 @@ internal fun PasswordBouncer( TextField( value = password, onValueChange = viewModel::onPasswordInputChanged, + enabled = isInputEnabled, visualTransformation = PasswordVisualTransformation(), singleLine = true, textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index 3afd33f4c90c5..e20833d7db173 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp import com.android.internal.R import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel +import com.android.systemui.compose.modifiers.thenIf import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt @@ -82,6 +83,8 @@ internal fun PatternBouncer( val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState() // The dots selected so far, if the user is currently dragging. val selectedDots: List by viewModel.selectedDots.collectAsState() + val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() + val isAnimationEnabled: Boolean by viewModel.isPatternVisible.collectAsState() // Map of animatables for the scale of each dot, keyed by dot. val dotScalingAnimatables = remember(dots) { dots.associateWith { Animatable(1f) } } @@ -96,16 +99,24 @@ internal fun PatternBouncer( val view = LocalView.current // When the current dot is changed, we need to update our animations. - LaunchedEffect(currentDot) { + LaunchedEffect(currentDot, isAnimationEnabled) { view.performHapticFeedback( HapticFeedbackConstants.VIRTUAL_KEY, HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING, ) - // Make sure that the current dot is scaled up while the other dots are scaled back down. + if (!isAnimationEnabled) { + return@LaunchedEffect + } + + // Make sure that the current dot is scaled up while the other dots are scaled back + // down. dotScalingAnimatables.entries.forEach { (dot, animatable) -> val isSelected = dot == currentDot - launch { + // Launch using the longer-lived scope because we want these animations to proceed to + // completion even if the LaunchedEffect is canceled because its key objects have + // changed. + scope.launch { animatable.animateTo(if (isSelected) 2f else 1f) if (isSelected) { animatable.animateTo(1f) @@ -116,14 +127,18 @@ internal fun PatternBouncer( selectedDots.forEach { dot -> lineFadeOutAnimatables[dot]?.let { line -> if (!line.isRunning) { + // Launch using the longer-lived scope because we want these animations to + // proceed to completion even if the LaunchedEffect is canceled because its key + // objects have changed. scope.launch { if (dot == currentDot) { - // Reset the fade-out animation for the current dot. When the current - // dot is switched, this entire code block runs again for the newly - // selected dot. + // Reset the fade-out animation for the current dot. When the + // current dot is switched, this entire code block runs again for + // the newly selected dot. line.snapTo(1f) } else { - // For all non-current dots, make sure that the lines are fading out. + // For all non-current dots, make sure that the lines are fading + // out. line.animateTo( targetValue = 0f, animationSpec = @@ -148,27 +163,34 @@ internal fun PatternBouncer( // when it leaves the bounds of the dot grid. .clipToBounds() .onSizeChanged { containerSize = it } - .pointerInput(Unit) { - detectDragGestures( - onDragStart = { start -> - inputPosition = start - viewModel.onDragStart() - }, - onDragEnd = { - inputPosition = null - lineFadeOutAnimatables.values.forEach { animatable -> - scope.launch { animatable.animateTo(1f) } - } - viewModel.onDragEnd() - }, - ) { change, _ -> - inputPosition = change.position - viewModel.onDrag( - xPx = change.position.x, - yPx = change.position.y, - containerSizePx = containerSize.width, - verticalOffsetPx = verticalOffset, - ) + .thenIf(isInputEnabled) { + Modifier.pointerInput(Unit) { + detectDragGestures( + onDragStart = { start -> + inputPosition = start + viewModel.onDragStart() + }, + onDragEnd = { + inputPosition = null + if (isAnimationEnabled) { + lineFadeOutAnimatables.values.forEach { animatable -> + // Launch using the longer-lived scope because we want these + // animations to proceed to completion even if the surrounding + // scope is canceled. + scope.launch { animatable.animateTo(1f) } + } + } + viewModel.onDragEnd() + }, + ) { change, _ -> + inputPosition = change.position + viewModel.onDrag( + xPx = change.position.x, + yPx = change.position.y, + containerSizePx = containerSize.width, + verticalOffsetPx = verticalOffset, + ) + } } } ) { diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index 9c210c225ab39..cbd7b8806a77b 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -63,6 +63,7 @@ import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.common.shared.model.ContentDescription import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon +import com.android.systemui.compose.modifiers.thenIf import kotlin.math.max @Composable @@ -75,6 +76,7 @@ internal fun PinBouncer( // The length of the PIN input received so far, so we know how many dots to render. val pinLength: Pair by viewModel.pinLengths.collectAsState() + val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -116,6 +118,7 @@ internal fun PinBouncer( val digit = index + 1 PinButton( onClicked = { viewModel.onPinButtonClicked(digit) }, + isEnabled = isInputEnabled, ) { contentColor -> PinDigit(digit, contentColor) } @@ -124,6 +127,7 @@ internal fun PinBouncer( PinButton( onClicked = { viewModel.onBackspaceButtonClicked() }, onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, + isEnabled = isInputEnabled, isHighlighted = true, ) { contentColor -> PinIcon( @@ -138,6 +142,7 @@ internal fun PinBouncer( PinButton( onClicked = { viewModel.onPinButtonClicked(0) }, + isEnabled = isInputEnabled, ) { contentColor -> PinDigit(0, contentColor) } @@ -145,6 +150,7 @@ internal fun PinBouncer( PinButton( onClicked = { viewModel.onAuthenticateButtonClicked() }, isHighlighted = true, + isEnabled = isInputEnabled, ) { contentColor -> PinIcon( Icon.Resource( @@ -187,6 +193,7 @@ private fun PinIcon( @Composable private fun PinButton( onClicked: () -> Unit, + isEnabled: Boolean, modifier: Modifier = Modifier, onLongPressed: (() -> Unit)? = null, isHighlighted: Boolean = false, @@ -228,16 +235,18 @@ private fun PinButton( cornerRadius = CornerRadius(cornerRadius.toPx()), ) } - .pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed = true - tryAwaitRelease() - isPressed = false - }, - onTap = { onClicked() }, - onLongPress = onLongPressed?.let { { onLongPressed() } }, - ) + .thenIf(isEnabled) { + Modifier.pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + tryAwaitRelease() + isPressed = false + }, + onTap = { onClicked() }, + onLongPress = onLongPressed?.let { { onLongPressed() } }, + ) + } }, ) { content(contentColor) diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt index 02991bd47c6e8..984d9ab1c1be2 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -20,6 +20,7 @@ import android.content.Context import com.android.systemui.R import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.bouncer.shared.model.AuthenticationThrottledModel import com.android.systemui.dagger.qualifiers.Application import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -45,21 +47,6 @@ constructor( ) { private val interactor: BouncerInteractor = interactorFactory.create(containerName) - /** - * Whether updates to the message should be cross-animated from one message to another. - * - * If `false`, no animation should be applied, the message text should just be replaced - * instantly. - */ - val isMessageUpdateAnimationsEnabled: StateFlow = - interactor.throttling - .map { it == null } - .stateIn( - scope = applicationScope, - started = SharingStarted.WhileSubscribed(), - initialValue = interactor.throttling.value == null, - ) - private val isInputEnabled: StateFlow = interactor.throttling .map { it == null } @@ -104,13 +91,21 @@ constructor( ) /** The user-facing message to show in the bouncer. */ - val message: StateFlow = - interactor.message - .map { it ?: "" } + val message: StateFlow = + combine( + interactor.message, + interactor.throttling, + ) { message, throttling -> + toMessageViewModel(message, throttling) + } .stateIn( scope = applicationScope, started = SharingStarted.WhileSubscribed(), - initialValue = interactor.message.value ?: "", + initialValue = + toMessageViewModel( + message = interactor.message.value, + throttling = interactor.throttling.value, + ), ) private val _throttlingDialogMessage = MutableStateFlow(null) @@ -177,6 +172,28 @@ constructor( } } + private fun toMessageViewModel( + message: String?, + throttling: AuthenticationThrottledModel?, + ): MessageViewModel { + return MessageViewModel( + text = message ?: "", + isUpdateAnimated = throttling == null, + ) + } + + data class MessageViewModel( + val text: String, + + /** + * Whether updates to the message should be cross-animated from one message to another. + * + * If `false`, no animation should be applied, the message text should just be replaced + * instantly. + */ + val isUpdateAnimated: Boolean, + ) + @AssistedFactory interface Factory { fun create( diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt index b942ccbb51f32..e8c946cdd59d1 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt @@ -93,22 +93,21 @@ class BouncerViewModelTest : SysuiTestCase() { } @Test - fun isMessageUpdateAnimationsEnabled() = + fun message() = testScope.runTest { - val isMessageUpdateAnimationsEnabled by - collectLastValue(underTest.isMessageUpdateAnimationsEnabled) + val message by collectLastValue(underTest.message) val throttling by collectLastValue(bouncerInteractor.throttling) authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - assertThat(isMessageUpdateAnimationsEnabled).isTrue() + assertThat(message?.isUpdateAnimated).isTrue() repeat(BouncerInteractor.THROTTLE_EVERY) { // Wrong PIN. bouncerInteractor.authenticate(listOf(3, 4, 5, 6)) } - assertThat(isMessageUpdateAnimationsEnabled).isFalse() + assertThat(message?.isUpdateAnimated).isFalse() throttling?.totalDurationSec?.let { seconds -> advanceTimeBy(seconds * 1000L) } - assertThat(isMessageUpdateAnimationsEnabled).isTrue() + assertThat(message?.isUpdateAnimated).isTrue() } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt index b7b90de3b54a0..f436aa309ac50 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -85,7 +85,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { underTest.onShown() - assertThat(message).isEqualTo(ENTER_YOUR_PASSWORD) + assertThat(message?.text).isEqualTo(ENTER_YOUR_PASSWORD) assertThat(password).isEqualTo("") assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -109,7 +109,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { underTest.onPasswordInputChanged("password") - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() assertThat(password).isEqualTo("password") assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -156,7 +156,7 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { underTest.onAuthenticateKeyPressed() assertThat(password).isEqualTo("") - assertThat(message).isEqualTo(WRONG_PASSWORD) + assertThat(message?.text).isEqualTo(WRONG_PASSWORD) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) } @@ -179,13 +179,13 @@ class PasswordBouncerViewModelTest : SysuiTestCase() { underTest.onPasswordInputChanged("wrong") underTest.onAuthenticateKeyPressed() assertThat(password).isEqualTo("") - assertThat(message).isEqualTo(WRONG_PASSWORD) + assertThat(message?.text).isEqualTo(WRONG_PASSWORD) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) // Enter the correct password: underTest.onPasswordInputChanged("password") - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() underTest.onAuthenticateKeyPressed() diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt index b588ba2b2574d..d7d7154705edc 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt @@ -89,7 +89,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { underTest.onShown() - assertThat(message).isEqualTo(ENTER_YOUR_PATTERN) + assertThat(message?.text).isEqualTo(ENTER_YOUR_PATTERN) assertThat(selectedDots).isEmpty() assertThat(currentDot).isNull() assertThat(isUnlocked).isFalse() @@ -115,7 +115,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { underTest.onDragStart() - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() assertThat(selectedDots).isEmpty() assertThat(currentDot).isNull() assertThat(isUnlocked).isFalse() @@ -202,7 +202,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { assertThat(selectedDots).isEmpty() assertThat(currentDot).isNull() - assertThat(message).isEqualTo(WRONG_PATTERN) + assertThat(message?.text).isEqualTo(WRONG_PATTERN) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) } @@ -235,7 +235,7 @@ class PatternBouncerViewModelTest : SysuiTestCase() { underTest.onDragEnd() assertThat(selectedDots).isEmpty() assertThat(currentDot).isNull() - assertThat(message).isEqualTo(WRONG_PATTERN) + assertThat(message?.text).isEqualTo(WRONG_PATTERN) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt index 83f9687d7ac53..3bdaf05908888 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -94,7 +94,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onShown() - assertThat(message).isEqualTo(ENTER_YOUR_PIN) + assertThat(message?.text).isEqualTo(ENTER_YOUR_PIN) assertThat(pinLengths).isEqualTo(0 to 0) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -116,7 +116,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onPinButtonClicked(1) - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() assertThat(pinLengths).isEqualTo(0 to 1) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -140,7 +140,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onBackspaceButtonClicked() - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() assertThat(pinLengths).isEqualTo(1 to 0) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -170,7 +170,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { advanceTimeBy(PinBouncerViewModel.BACKSPACE_LONG_PRESS_DELAY_MS) } - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() assertThat(pinLengths).isEqualTo(1 to 0) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -220,7 +220,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onAuthenticateButtonClicked() assertThat(pinLengths).isEqualTo(0 to 0) - assertThat(message).isEqualTo(WRONG_PIN) + assertThat(message?.text).isEqualTo(WRONG_PIN) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) } @@ -244,7 +244,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onPinButtonClicked(4) underTest.onPinButtonClicked(5) // PIN is now wrong! underTest.onAuthenticateButtonClicked() - assertThat(message).isEqualTo(WRONG_PIN) + assertThat(message?.text).isEqualTo(WRONG_PIN) assertThat(pinLengths).isEqualTo(0 to 0) assertThat(isUnlocked).isFalse() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -254,7 +254,7 @@ class PinBouncerViewModelTest : SysuiTestCase() { underTest.onPinButtonClicked(2) underTest.onPinButtonClicked(3) underTest.onPinButtonClicked(4) - assertThat(message).isEmpty() + assertThat(message?.text).isEmpty() underTest.onAuthenticateButtonClicked() From 4e9af5b7592bc0abccf639c8e771c34e001baef4 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Thu, 18 May 2023 09:50:46 -0700 Subject: [PATCH 3/6] [flexiglass] Fixes extraneous haptic feedback in pattern bouncer. There's a bug where we vibrate when we first open the bouncer UI in the pattern configuration and when we stop dragging. This CL fixes that. Bug: 281871687 Test: manually verified that, after the fix, we only get haptic feedback when selecting a new dot and not when we first bring up the UI nor when we stop dragging. Change-Id: I808698e74a3d9300c11b7422fc70dfdfc5fea80b --- .../systemui/bouncer/ui/composable/PatternBouncer.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index e20833d7db173..0609d1409c076 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -100,10 +100,14 @@ internal fun PatternBouncer( // When the current dot is changed, we need to update our animations. LaunchedEffect(currentDot, isAnimationEnabled) { - view.performHapticFeedback( - HapticFeedbackConstants.VIRTUAL_KEY, - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING, - ) + // 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, + ) + } if (!isAnimationEnabled) { return@LaunchedEffect From 2515b7efaf13e8c3427e311aa5c24ef52a1c28e5 Mon Sep 17 00:00:00 2001 From: Mike Schneider Date: Wed, 17 May 2023 08:01:41 +0200 Subject: [PATCH 4/6] [flexiglass] Pin bouncer UX polish. This aligns the implementation closer to the go/android-u-bouncer-motion spec - adds haptic feedback on pin touch - updates colors to better match spec (but not all color tokens are available in code yet) - update animation easing and durations to match spec - add a hold time to short button presses. Bug: 282730134 Test: Manual verification. Please see video capture attached to b/282730134. Change-Id: Icdf24c17666fb85b0e7c7d28f1d46cb903cfe54c --- .../com/android/compose/animation/Easings.kt | 63 ++++++++++++++ .../bouncer/ui/composable/PinBouncer.kt | 85 +++++++++++++++---- 2 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt diff --git a/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt b/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt new file mode 100644 index 0000000000000..8fe1f48dcaeeb --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/animation/Easings.kt @@ -0,0 +1,63 @@ +/* + * 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.compose.animation + +import androidx.compose.animation.core.Easing +import androidx.core.animation.Interpolator +import com.android.app.animation.InterpolatorsAndroidX + +/** + * Compose-compatible definition of Android motion eases, see + * https://carbon.googleplex.com/android-motion/pages/easing + */ +object Easings { + + /** The standard interpolator that should be used on every normal animation */ + val StandardEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD) + + /** + * The standard accelerating interpolator that should be used on every regular movement of + * content that is disappearing e.g. when moving off screen. + */ + val StandardAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_ACCELERATE) + + /** + * The standard decelerating interpolator that should be used on every regular movement of + * content that is appearing e.g. when coming from off screen. + */ + val StandardDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.STANDARD_DECELERATE) + + /** The default emphasized interpolator. Used for hero / emphasized movement of content. */ + val EmphasizedEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED) + + /** + * The accelerated emphasized interpolator. Used for hero / emphasized movement of content that + * is disappearing e.g. when moving off screen. + */ + val EmphasizedAccelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_ACCELERATE) + + /** + * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that + * is appearing e.g. when coming from off screen + */ + val EmphasizedDecelerateEasing = fromInterpolator(InterpolatorsAndroidX.EMPHASIZED_DECELERATE) + + /** The linear interpolator. */ + val LinearEasing = fromInterpolator(InterpolatorsAndroidX.LINEAR) + + private fun fromInterpolator(source: Interpolator) = Easing { x -> source.getInterpolation(x) } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index cbd7b8806a77b..2ca78b1ad1956 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -18,11 +18,15 @@ package com.android.systemui.bouncer.ui.composable +import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn @@ -48,6 +52,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -55,8 +60,10 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.android.compose.animation.Easings import com.android.compose.grid.VerticalGrid import com.android.systemui.R import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel @@ -65,6 +72,11 @@ import com.android.systemui.common.shared.model.Icon import com.android.systemui.common.ui.compose.Icon import com.android.systemui.compose.modifiers.thenIf import kotlin.math.max +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @Composable internal fun PinBouncer( @@ -128,7 +140,7 @@ internal fun PinBouncer( onClicked = { viewModel.onBackspaceButtonClicked() }, onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, isEnabled = isInputEnabled, - isHighlighted = true, + isIconButton = true, ) { contentColor -> PinIcon( Icon.Resource( @@ -149,8 +161,8 @@ internal fun PinBouncer( PinButton( onClicked = { viewModel.onAuthenticateButtonClicked() }, - isHighlighted = true, isEnabled = isInputEnabled, + isIconButton = true, ) { contentColor -> PinIcon( Icon.Resource( @@ -196,39 +208,65 @@ private fun PinButton( isEnabled: Boolean, modifier: Modifier = Modifier, onLongPressed: (() -> Unit)? = null, - isHighlighted: Boolean = false, + isIconButton: Boolean = false, content: @Composable (contentColor: Color) -> Unit, ) { var isPressed: Boolean by remember { mutableStateOf(false) } + + 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 + // press animation duration (see below in detectTapGestures). + val animEasing = if (isPressed) pinButtonPressedEasing else pinButtonReleasedEasing + val animDurationMillis = + (if (isPressed) pinButtonPressedDuration else pinButtonReleasedDuration).toInt( + DurationUnit.MILLISECONDS + ) + val cornerRadius: Dp by animateDpAsState( - if (isPressed) 24.dp else PinButtonSize / 2, + if (isPressed) 24.dp else pinButtonSize / 2, label = "PinButton round corners", + animationSpec = tween(animDurationMillis, easing = animEasing) ) + val colorAnimationSpec: AnimationSpec = tween(animDurationMillis, easing = animEasing) val containerColor: Color by animateColorAsState( when { - isPressed -> MaterialTheme.colorScheme.primaryContainer - isHighlighted -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surface + isPressed -> MaterialTheme.colorScheme.primary + isIconButton -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant }, label = "Pin button container color", + animationSpec = colorAnimationSpec ) val contentColor: Color by animateColorAsState( when { - isPressed -> MaterialTheme.colorScheme.onPrimaryContainer - isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer - else -> MaterialTheme.colorScheme.onSurface + isPressed -> MaterialTheme.colorScheme.onPrimary + isIconButton -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant }, label = "Pin button container color", + animationSpec = colorAnimationSpec ) + val scope = rememberCoroutineScope() + Box( contentAlignment = Alignment.Center, modifier = modifier - .size(PinButtonSize) + .size(pinButtonSize) .drawBehind { drawRoundRect( color = containerColor, @@ -239,9 +277,15 @@ private fun PinButton( Modifier.pointerInput(Unit) { detectTapGestures( onPress = { - isPressed = true - tryAwaitRelease() - isPressed = false + scope.launch { + isPressed = true + val minDuration = async { + delay(pinButtonPressedDuration + pinButtonHoldTime) + } + tryAwaitRelease() + minDuration.await() + isPressed = false + } }, onTap = { onClicked() }, onLongPress = onLongPressed?.let { { onLongPressed() } }, @@ -253,4 +297,15 @@ private fun PinButton( } } -private val PinButtonSize = 84.dp +private fun showFailureAnimation() { + // TODO(b/282730134): implement. +} + +private val pinButtonSize = 84.dp + +// Pin button motion spec: http://shortn/_9TTIG6SoEa +private val pinButtonPressedDuration = 100.milliseconds +private val pinButtonPressedEasing = LinearEasing +private val pinButtonHoldTime = 33.milliseconds +private val pinButtonReleasedDuration = 420.milliseconds +private val pinButtonReleasedEasing = Easings.StandardEasing From 70f9b87debe03226874d1094cc1d0269d2dd0230 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 19 May 2023 08:56:34 -0700 Subject: [PATCH 5/6] [flexiglass] Pattern bouncer motion UX polish. Makes the dot animation for scaling/unscaling match the UX spec. Bug: 281871687 Test: manually verified that it looks good. See b/281871687#comment3 for a video capture. Change-Id: Ibcaa5d24689187d139fc7a01047a028692a9cfa6 --- .../bouncer/ui/composable/PatternBouncer.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index 0609d1409c076..e8051bcb331be 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.integerResource import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import com.android.compose.animation.Easings import com.android.internal.R import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel @@ -67,9 +68,9 @@ internal fun PatternBouncer( val rowCount = viewModel.rowCount val dotColor = MaterialTheme.colorScheme.secondary - val dotRadius = with(LocalDensity.current) { 8.dp.toPx() } + val dotRadius = with(LocalDensity.current) { (DOT_DIAMETER_DP / 2).dp.toPx() } val lineColor = MaterialTheme.colorScheme.primary - val lineStrokeWidth = dotRadius * 2 + with(LocalDensity.current) { 4.dp.toPx() } + val lineStrokeWidth = with(LocalDensity.current) { LINE_STROKE_WIDTH_DP.dp.toPx() } var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) } val horizontalSpacing = containerSize.width / colCount @@ -121,9 +122,24 @@ internal fun PatternBouncer( // completion even if the LaunchedEffect is canceled because its key objects have // changed. scope.launch { - animatable.animateTo(if (isSelected) 2f else 1f) if (isSelected) { - animatable.animateTo(1f) + animatable.animateTo( + targetValue = (SELECTED_DOT_DIAMETER_DP / DOT_DIAMETER_DP.toFloat()), + animationSpec = + tween( + durationMillis = SELECTED_DOT_REACTION_ANIMATION_DURATION_MS, + easing = Easings.StandardAccelerateEasing, + ), + ) + } else { + animatable.animateTo( + targetValue = 1f, + animationSpec = + tween( + durationMillis = SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS, + easing = Easings.StandardDecelerateEasing, + ), + ) } } } @@ -273,3 +289,9 @@ private fun lineAlpha(gridSpacing: Float, lineLength: Float = gridSpacing): Floa // farther the user input pointer goes from the line, the more opaque the line gets. return ((lineLength / gridSpacing - 0.3f) * 4f).coerceIn(0f, 1f) } + +private const val DOT_DIAMETER_DP = 16 +private const val SELECTED_DOT_DIAMETER_DP = 24 +private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83 +private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750 +private const val LINE_STROKE_WIDTH_DP = 16 From 1b00a65478721950ca82dcf9ccfdab0d302791d7 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 19 May 2023 11:14:05 -0700 Subject: [PATCH 6/6] [flexiglass] Failure animations for bouncer UIs. - Adds a generic failure animation system to AuthMethodBouncerViewModel that all subclasses can easily use - Introduces a use-case for it in PatternBouncerViewModel and a stub in PinBouncerViewModel for later implementation Bug: 281871687 Test: Added unit tests for the failure animation UI state and logic in AuthMethodBouncerViewModel, using PinBouncerViewModel as the underTest instance because AuthMethodBouncerViewModel is a sealed class so it cannot be extended, even for tests. Test: manually/visually verified the staggered failuew animation in the pattern bouncer UI. Please see b/281871687#comment4 for a video recording. Change-Id: I4d4d81e4d6e32943ae2fc715188e0f62dc28111d --- .../bouncer/ui/composable/PasswordBouncer.kt | 8 ++ .../bouncer/ui/composable/PatternBouncer.kt | 67 +++++++++++++++ .../bouncer/ui/composable/PinBouncer.kt | 9 +++ .../domain/interactor/BouncerInteractor.kt | 11 ++- .../viewmodel/AuthMethodBouncerViewModel.kt | 27 ++++++- .../ui/viewmodel/PasswordBouncerViewModel.kt | 12 ++- .../ui/viewmodel/PatternBouncerViewModel.kt | 13 ++- .../ui/viewmodel/PinBouncerViewModel.kt | 12 ++- .../interactor/BouncerInteractorTest.kt | 25 +++--- .../AuthMethodBouncerViewModelTest.kt | 81 +++++++++++++++++++ 10 files changed, 241 insertions(+), 24 deletions(-) create mode 100644 packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt index 01346c7170dae..7545ff464bab0 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -54,6 +54,7 @@ internal fun PasswordBouncer( val focusRequester = remember { FocusRequester() } val password: String by viewModel.password.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState() + val animateFailure: Boolean by viewModel.animateFailure.collectAsState() LaunchedEffect(Unit) { // 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() } + LaunchedEffect(animateFailure) { + if (animateFailure) { + // We don't currently have a failure animation for password, just consume it: + viewModel.onFailureAnimationShown() + } + } + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier, diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt index e8051bcb331be..88441146ad23e 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -18,6 +18,7 @@ package com.android.systemui.bouncer.ui.composable import android.view.HapticFeedbackConstants import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectDragGestures @@ -49,6 +50,7 @@ import com.android.systemui.compose.modifiers.thenIf import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch /** @@ -86,6 +88,7 @@ internal fun PatternBouncer( val selectedDots: List by viewModel.selectedDots.collectAsState() val isInputEnabled: Boolean by viewModel.isInputEnabled.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. 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. 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) } +private suspend fun showFailureAnimation( + dots: List, + scalingAnimatables: Map>, +) { + val dotsByRow = + buildList> { + 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 SELECTED_DOT_DIAMETER_DP = 24 private const val SELECTED_DOT_REACTION_ANIMATION_DURATION_MS = 83 private const val SELECTED_DOT_RETRACT_ANIMATION_DURATION_MS = 750 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 diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt index 2ca78b1ad1956..968e5ab8ad8c4 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -89,6 +89,15 @@ internal fun PinBouncer( // The length of the PIN input received so far, so we know how many dots to render. val pinLength: Pair by viewModel.pinLengths.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( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt index e462e2f5b7d8c..1d2fce7d8b05f 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractor.kt @@ -148,12 +148,17 @@ constructor( * * If the input is correct, the device will be unlocked and the lock screen and bouncer will be * 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( input: List, - ) { + ): Boolean { if (repository.throttling.value != null) { - return + return false } val isAuthenticated = authenticationInteractor.authenticate(input) @@ -186,6 +191,8 @@ constructor( } else -> repository.setMessage(errorMessage(authenticationMethod.value)) } + + return isAuthenticated } private fun promptMessage(authMethod: AuthenticationMethodModel): String { diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt index 774a5593530ca..d95b70c85fe0f 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -16,14 +16,37 @@ package com.android.systemui.bouncer.ui.viewmodel +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow -sealed interface AuthMethodBouncerViewModel { +sealed class AuthMethodBouncerViewModel( /** * Whether user input is enabled. * * 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. */ - val isInputEnabled: StateFlow + val isInputEnabled: StateFlow, +) { + + 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 = _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 + } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt index c38fcaa3b657e..55929b566cf1e 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -24,8 +24,11 @@ import kotlinx.coroutines.flow.asStateFlow /** Holds UI state and handles user input for the password bouncer UI. */ class PasswordBouncerViewModel( private val interactor: BouncerInteractor, - override val isInputEnabled: StateFlow, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + AuthMethodBouncerViewModel( + isInputEnabled = isInputEnabled, + ) { private val _password = MutableStateFlow("") /** 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. */ fun onAuthenticateKeyPressed() { - interactor.authenticate(password.value.toCharArray().toList()) + if (!interactor.authenticate(password.value.toCharArray().toList())) { + showFailureAnimation() + } + _password.value = "" } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt index 1b0b38ea6e9cf..d9ef75db6103b 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -37,8 +37,11 @@ class PatternBouncerViewModel( private val applicationContext: Context, applicationScope: CoroutineScope, private val interactor: BouncerInteractor, - override val isInputEnabled: StateFlow, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + AuthMethodBouncerViewModel( + isInputEnabled = isInputEnabled, + ) { /** The number of columns in the dot grid. */ val columnCount = 3 @@ -150,7 +153,11 @@ class PatternBouncerViewModel( /** Notifies that the user has ended the drag gesture across the dot grid. */ fun onDragEnd() { - interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) + val isSuccessfullyAuthenticated = + interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) + if (!isSuccessfullyAuthenticated) { + showFailureAnimation() + } _dots.value = defaultDots() _currentDot.value = null diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt index 2a733d93b857f..5c0fd92e72997 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -33,8 +33,11 @@ import kotlinx.coroutines.launch class PinBouncerViewModel( private val applicationScope: CoroutineScope, private val interactor: BouncerInteractor, - override val isInputEnabled: StateFlow, -) : AuthMethodBouncerViewModel { + isInputEnabled: StateFlow, +) : + AuthMethodBouncerViewModel( + isInputEnabled = isInputEnabled, + ) { private val entered = MutableStateFlow>(emptyList()) /** @@ -92,7 +95,10 @@ class PinBouncerViewModel( /** Notifies that the user clicked the "enter" button. */ fun onAuthenticateButtonClicked() { - interactor.authenticate(entered.value) + if (!interactor.authenticate(entered.value)) { + showFailureAnimation() + } + entered.value = emptyList() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 9f5c181c31293..374c28d6dce88 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -82,7 +82,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) // Wrong input. - underTest.authenticate(listOf(9, 8, 7)) + assertThat(underTest.authenticate(listOf(9, 8, 7))).isFalse() assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -90,7 +90,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PIN) // Correct input. - underTest.authenticate(listOf(1, 2, 3, 4)) + assertThat(underTest.authenticate(listOf(1, 2, 3, 4))).isTrue() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } @@ -114,7 +114,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) // Wrong input. - underTest.authenticate("alohamora".toList()) + assertThat(underTest.authenticate("alohamora".toList())).isFalse() assertThat(message).isEqualTo(MESSAGE_WRONG_PASSWORD) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -122,7 +122,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PASSWORD) // Correct input. - underTest.authenticate("password".toList()) + assertThat(underTest.authenticate("password".toList())).isTrue() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } @@ -146,9 +146,12 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) // Wrong input. - underTest.authenticate( - listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4)) - ) + assertThat( + underTest.authenticate( + listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(3, 4)) + ) + ) + .isFalse() assertThat(message).isEqualTo(MESSAGE_WRONG_PATTERN) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) @@ -156,7 +159,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(message).isEqualTo(MESSAGE_ENTER_YOUR_PATTERN) // Correct input. - underTest.authenticate(emptyList()) + assertThat(underTest.authenticate(emptyList())).isTrue() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } @@ -214,7 +217,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(isUnlocked).isFalse() repeat(BouncerInteractor.THROTTLE_EVERY) { times -> // Wrong PIN. - underTest.authenticate(listOf(6, 7, 8, 9)) + assertThat(underTest.authenticate(listOf(6, 7, 8, 9))).isFalse() if (times < BouncerInteractor.THROTTLE_EVERY - 1) { assertThat(message).isEqualTo(MESSAGE_WRONG_PIN) } @@ -223,7 +226,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) // 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() assertTryAgainMessage(message, BouncerInteractor.THROTTLE_DURATION_SEC) @@ -241,7 +244,7 @@ class BouncerInteractorTest : SysuiTestCase() { assertThat(isUnlocked).isFalse() // 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() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt new file mode 100644 index 0000000000000..1642410e5a3fb --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModelTest.kt @@ -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() + } +}