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

Commit 33ddd9ab authored by Alejandro Nijamkin's avatar Alejandro Nijamkin
Browse files

[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
parent 4b5abe26
Loading
Loading
Loading
Loading
+4 −0
Original line number Original line Diff line number Diff line
@@ -16,6 +16,7 @@


package com.android.systemui.scene.ui.composable
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.composable.BouncerScene
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.dagger.SysUISingleton
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.scene.shared.model.SceneContainerNames
import com.android.systemui.shade.ui.composable.ShadeScene
import com.android.systemui.shade.ui.composable.ShadeScene
import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
import com.android.systemui.shade.ui.viewmodel.ShadeSceneViewModel
import com.android.systemui.statusbar.phone.SystemUIDialog
import dagger.Module
import dagger.Module
import dagger.Provides
import dagger.Provides
import javax.inject.Named
import javax.inject.Named
@@ -57,6 +59,7 @@ object SceneModule {
    @SysUISingleton
    @SysUISingleton
    @Named(SceneContainerNames.SYSTEM_UI_DEFAULT)
    @Named(SceneContainerNames.SYSTEM_UI_DEFAULT)
    fun bouncerScene(
    fun bouncerScene(
        @Application context: Context,
        viewModelFactory: BouncerViewModel.Factory,
        viewModelFactory: BouncerViewModel.Factory,
    ): BouncerScene {
    ): BouncerScene {
        return BouncerScene(
        return BouncerScene(
@@ -64,6 +67,7 @@ object SceneModule {
                viewModelFactory.create(
                viewModelFactory.create(
                    containerName = SceneContainerNames.SYSTEM_UI_DEFAULT,
                    containerName = SceneContainerNames.SYSTEM_UI_DEFAULT,
                ),
                ),
            dialogFactory = { SystemUIDialog(context) },
        )
        )
    }
    }


+42 −4
Original line number Original line Diff line number Diff line
@@ -14,9 +14,16 @@
 * limitations under the License.
 * limitations under the License.
 */
 */


@file:OptIn(ExperimentalMaterial3Api::class)

package com.android.systemui.bouncer.ui.composable
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.Crossfade
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.AuthMethodBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel
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. */
/** The bouncer scene displays authentication challenges like PIN, password, or pattern. */
class BouncerScene(
class BouncerScene(
    private val viewModel: BouncerViewModel,
    private val viewModel: BouncerViewModel,
    private val dialogFactory: () -> AlertDialog,
) : ComposableScene {
) : ComposableScene {
    override val key = SceneKey.Bouncer
    override val key = SceneKey.Bouncer


@@ -68,16 +81,19 @@ class BouncerScene(
    override fun Content(
    override fun Content(
        containerName: String,
        containerName: String,
        modifier: Modifier,
        modifier: Modifier,
    ) = BouncerScene(viewModel, modifier)
    ) = BouncerScene(viewModel, dialogFactory, modifier)
}
}


@Composable
@Composable
private fun BouncerScene(
private fun BouncerScene(
    viewModel: BouncerViewModel,
    viewModel: BouncerViewModel,
    dialogFactory: () -> AlertDialog,
    modifier: Modifier = Modifier,
    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 authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState()
    val dialogMessage: String? by viewModel.throttlingDialogMessage.collectAsState()
    var dialog: Dialog? by remember { mutableStateOf(null) }


    Column(
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        horizontalAlignment = Alignment.CenterHorizontally,
@@ -88,9 +104,10 @@ private fun BouncerScene(
        Crossfade(
        Crossfade(
            targetState = message,
            targetState = message,
            label = "Bouncer message",
            label = "Bouncer message",
        ) {
            animationSpec = if (message.isUpdateAnimated) tween() else snap(),
        ) { message ->
            Text(
            Text(
                text = it,
                text = message.text,
                color = MaterialTheme.colorScheme.onSurface,
                color = MaterialTheme.colorScheme.onSurface,
                style = MaterialTheme.typography.bodyLarge,
                style = MaterialTheme.typography.bodyLarge,
            )
            )
@@ -132,5 +149,26 @@ private fun BouncerScene(
                style = MaterialTheme.typography.bodyMedium,
                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
        }
    }
    }
}
}
+2 −0
Original line number Original line Diff line number Diff line
@@ -53,6 +53,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()


    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.
@@ -71,6 +72,7 @@ internal fun PasswordBouncer(
        TextField(
        TextField(
            value = password,
            value = password,
            onValueChange = viewModel::onPasswordInputChanged,
            onValueChange = viewModel::onPasswordInputChanged,
            enabled = isInputEnabled,
            visualTransformation = PasswordVisualTransformation(),
            visualTransformation = PasswordVisualTransformation(),
            singleLine = true,
            singleLine = true,
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
            textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center),
+50 −28
Original line number Original line Diff line number Diff line
@@ -44,6 +44,7 @@ import androidx.compose.ui.unit.dp
import com.android.internal.R
import com.android.internal.R
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel
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
@@ -82,6 +83,8 @@ internal fun PatternBouncer(
    val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
    val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState()
    // The dots selected so far, if the user is currently dragging.
    // The dots selected so far, if the user is currently dragging.
    val selectedDots: List<PatternDotViewModel> by viewModel.selectedDots.collectAsState()
    val selectedDots: List<PatternDotViewModel> 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.
    // 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) } }
@@ -96,16 +99,24 @@ internal fun PatternBouncer(
    val view = LocalView.current
    val view = LocalView.current


    // When the current dot is changed, we need to update our animations.
    // When the current dot is changed, we need to update our animations.
    LaunchedEffect(currentDot) {
    LaunchedEffect(currentDot, isAnimationEnabled) {
        view.performHapticFeedback(
        view.performHapticFeedback(
            HapticFeedbackConstants.VIRTUAL_KEY,
            HapticFeedbackConstants.VIRTUAL_KEY,
            HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING,
            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) ->
        dotScalingAnimatables.entries.forEach { (dot, animatable) ->
            val isSelected = dot == currentDot
            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)
                animatable.animateTo(if (isSelected) 2f else 1f)
                if (isSelected) {
                if (isSelected) {
                    animatable.animateTo(1f)
                    animatable.animateTo(1f)
@@ -116,14 +127,18 @@ internal fun PatternBouncer(
        selectedDots.forEach { dot ->
        selectedDots.forEach { dot ->
            lineFadeOutAnimatables[dot]?.let { line ->
            lineFadeOutAnimatables[dot]?.let { line ->
                if (!line.isRunning) {
                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 {
                    scope.launch {
                        if (dot == currentDot) {
                        if (dot == currentDot) {
                            // Reset the fade-out animation for the current dot. When the current
                            // Reset the fade-out animation for the current dot. When the
                            // dot is switched, this entire code block runs again for the newly
                            // current dot is switched, this entire code block runs again for
                            // selected dot.
                            // the newly selected dot.
                            line.snapTo(1f)
                            line.snapTo(1f)
                        } else {
                        } 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(
                            line.animateTo(
                                targetValue = 0f,
                                targetValue = 0f,
                                animationSpec =
                                animationSpec =
@@ -148,7 +163,8 @@ internal fun PatternBouncer(
            // when it leaves the bounds of the dot grid.
            // when it leaves the bounds of the dot grid.
            .clipToBounds()
            .clipToBounds()
            .onSizeChanged { containerSize = it }
            .onSizeChanged { containerSize = it }
            .pointerInput(Unit) {
            .thenIf(isInputEnabled) {
                Modifier.pointerInput(Unit) {
                    detectDragGestures(
                    detectDragGestures(
                        onDragStart = { start ->
                        onDragStart = { start ->
                            inputPosition = start
                            inputPosition = start
@@ -156,9 +172,14 @@ internal fun PatternBouncer(
                        },
                        },
                        onDragEnd = {
                        onDragEnd = {
                            inputPosition = null
                            inputPosition = null
                            if (isAnimationEnabled) {
                                lineFadeOutAnimatables.values.forEach { animatable ->
                                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) }
                                    scope.launch { animatable.animateTo(1f) }
                                }
                                }
                            }
                            viewModel.onDragEnd()
                            viewModel.onDragEnd()
                        },
                        },
                    ) { change, _ ->
                    ) { change, _ ->
@@ -171,6 +192,7 @@ internal fun PatternBouncer(
                        )
                        )
                    }
                    }
                }
                }
            }
    ) {
    ) {
        // Draw lines between dots.
        // Draw lines between dots.
        selectedDots.forEachIndexed { index, dot ->
        selectedDots.forEachIndexed { index, dot ->
+19 −10
Original line number Original line Diff line number Diff line
@@ -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.ContentDescription
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.shared.model.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.common.ui.compose.Icon
import com.android.systemui.compose.modifiers.thenIf
import kotlin.math.max
import kotlin.math.max


@Composable
@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.
    // 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()


    Column(
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        horizontalAlignment = Alignment.CenterHorizontally,
@@ -116,6 +118,7 @@ internal fun PinBouncer(
                val digit = index + 1
                val digit = index + 1
                PinButton(
                PinButton(
                    onClicked = { viewModel.onPinButtonClicked(digit) },
                    onClicked = { viewModel.onPinButtonClicked(digit) },
                    isEnabled = isInputEnabled,
                ) { contentColor ->
                ) { contentColor ->
                    PinDigit(digit, contentColor)
                    PinDigit(digit, contentColor)
                }
                }
@@ -124,6 +127,7 @@ internal fun PinBouncer(
            PinButton(
            PinButton(
                onClicked = { viewModel.onBackspaceButtonClicked() },
                onClicked = { viewModel.onBackspaceButtonClicked() },
                onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
                onLongPressed = { viewModel.onBackspaceButtonLongPressed() },
                isEnabled = isInputEnabled,
                isHighlighted = true,
                isHighlighted = true,
            ) { contentColor ->
            ) { contentColor ->
                PinIcon(
                PinIcon(
@@ -138,6 +142,7 @@ internal fun PinBouncer(


            PinButton(
            PinButton(
                onClicked = { viewModel.onPinButtonClicked(0) },
                onClicked = { viewModel.onPinButtonClicked(0) },
                isEnabled = isInputEnabled,
            ) { contentColor ->
            ) { contentColor ->
                PinDigit(0, contentColor)
                PinDigit(0, contentColor)
            }
            }
@@ -145,6 +150,7 @@ internal fun PinBouncer(
            PinButton(
            PinButton(
                onClicked = { viewModel.onAuthenticateButtonClicked() },
                onClicked = { viewModel.onAuthenticateButtonClicked() },
                isHighlighted = true,
                isHighlighted = true,
                isEnabled = isInputEnabled,
            ) { contentColor ->
            ) { contentColor ->
                PinIcon(
                PinIcon(
                    Icon.Resource(
                    Icon.Resource(
@@ -187,6 +193,7 @@ private fun PinIcon(
@Composable
@Composable
private fun PinButton(
private fun PinButton(
    onClicked: () -> Unit,
    onClicked: () -> Unit,
    isEnabled: Boolean,
    modifier: Modifier = Modifier,
    modifier: Modifier = Modifier,
    onLongPressed: (() -> Unit)? = null,
    onLongPressed: (() -> Unit)? = null,
    isHighlighted: Boolean = false,
    isHighlighted: Boolean = false,
@@ -228,7 +235,8 @@ private fun PinButton(
                        cornerRadius = CornerRadius(cornerRadius.toPx()),
                        cornerRadius = CornerRadius(cornerRadius.toPx()),
                    )
                    )
                }
                }
                .pointerInput(Unit) {
                .thenIf(isEnabled) {
                    Modifier.pointerInput(Unit) {
                        detectTapGestures(
                        detectTapGestures(
                            onPress = {
                            onPress = {
                                isPressed = true
                                isPressed = true
@@ -238,6 +246,7 @@ private fun PinButton(
                            onTap = { onClicked() },
                            onTap = { onClicked() },
                            onLongPress = onLongPressed?.let { { onLongPressed() } },
                            onLongPress = onLongPressed?.let { { onLongPressed() } },
                        )
                        )
                    }
                },
                },
    ) {
    ) {
        content(contentColor)
        content(contentColor)
Loading