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

Commit 8c50659a authored by Mike Schneider's avatar Mike Schneider
Browse files

[flexiglass] Pin display polish.

This aligns the implementation closer to the go/android-u-bouncer-motion spec

- dots overshoot
- removing digits collapses them together with the shift animation
- clear all produces a staggered collapse effect

Bug: 282730134
Test: Manual verification. Please see video capture attached to b/282730134.
Test: Model unit tests
Change-Id: Ic3c2a257b4cac0fa81b0365e7da07798b342847b
parent a73b99ad
Loading
Loading
Loading
Loading
+15 −0
Original line number Diff line number Diff line
@@ -59,5 +59,20 @@ object Easings {
    /** The linear interpolator. */
    val Linear = fromInterpolator(InterpolatorsAndroidX.LINEAR)

    /** The default legacy interpolator as defined in Material 1. Also known as FAST_OUT_SLOW_IN. */
    val Legacy = fromInterpolator(InterpolatorsAndroidX.LEGACY)

    /**
     * The default legacy accelerating interpolator as defined in Material 1. Also known as
     * FAST_OUT_LINEAR_IN.
     */
    val LegacyAccelerate = fromInterpolator(InterpolatorsAndroidX.LEGACY_ACCELERATE)

    /**
     * T The default legacy decelerating interpolator as defined in Material 1. Also known as
     * LINEAR_OUT_SLOW_IN.
     */
    val LegacyDecelerate = fromInterpolator(InterpolatorsAndroidX.LEGACY_DECELERATE)

    private fun fromInterpolator(source: Interpolator) = Easing { x -> source.getInterpolation(x) }
}
+160 −40
Original line number Diff line number Diff line
@@ -19,23 +19,19 @@
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.MutableTransitionState
import androidx.compose.animation.core.Transition
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.keyframes
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -43,35 +39,40 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.layout.Layout
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Constraints
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.EnteredKey
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
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.DurationUnit
import kotlinx.coroutines.async
@@ -86,8 +87,6 @@ internal fun PinBouncer(
    // Report that the UI is shown to let the view-model run some logic.
    LaunchedEffect(Unit) { viewModel.onShown() }

    // 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 isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
    val animateFailure: Boolean by viewModel.animateFailure.collectAsState()

@@ -103,30 +102,7 @@ internal fun PinBouncer(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = modifier,
    ) {
        Row(
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            modifier = Modifier.heightIn(min = 16.dp).animateContentSize(),
        ) {
            // TODO(b/281871687): add support for dot shapes.
            val (previousPinLength, currentPinLength) = pinLength
            val dotCount = max(previousPinLength, currentPinLength) + 1
            repeat(dotCount) { index ->
                AnimatedVisibility(
                    visible = index < currentPinLength,
                    enter = fadeIn() + scaleIn() + slideInHorizontally(),
                    exit = fadeOut() + scaleOut() + slideOutHorizontally(),
                ) {
                    Box(
                        modifier =
                            Modifier.size(16.dp)
                                .background(
                                    MaterialTheme.colorScheme.onSurfaceVariant,
                                    CircleShape,
                                )
                    )
                }
            }
        }
        PinInputDisplay(viewModel)

        Spacer(Modifier.height(100.dp))

@@ -186,6 +162,148 @@ internal fun PinBouncer(
    }
}

@Composable
private fun PinInputDisplay(viewModel: PinBouncerViewModel) {
    val currentPinEntries: List<EnteredKey> by viewModel.pinEntries.collectAsState()

    // visiblePinEntries keeps pins removed from currentPinEntries in the composition until their
    // disappear-animation completed. The list is sorted by the natural ordering of EnteredKey,
    // which is guaranteed to produce the original edit order, since the model only modifies entries
    // at the end.
    val visiblePinEntries = remember { SnapshotStateList<EnteredKey>() }
    currentPinEntries.forEach {
        val index = visiblePinEntries.binarySearch(it)
        if (index < 0) {
            val insertionPoint = -(index + 1)
            visiblePinEntries.add(insertionPoint, it)
        }
    }

    Row(
        modifier =
            Modifier.heightIn(min = entryShapeSize)
                // Pins overflowing horizontally should still be shown as scrolling.
                .wrapContentSize(unbounded = true),
    ) {
        visiblePinEntries.forEachIndexed { index, entry ->
            key(entry) {
                val visibility = remember {
                    MutableTransitionState<EntryVisibility>(EntryVisibility.Hidden)
                }
                visibility.targetState =
                    when {
                        currentPinEntries.isEmpty() && visiblePinEntries.size > 1 ->
                            EntryVisibility.BulkHidden(index, visiblePinEntries.size)
                        currentPinEntries.contains(entry) -> EntryVisibility.Shown
                        else -> EntryVisibility.Hidden
                    }

                ObscuredInputEntry(updateTransition(visibility, label = "Pin Entry $entry"))

                LaunchedEffect(entry) {
                    // Remove entry from visiblePinEntries once the hide transition completed.
                    snapshotFlow {
                            visibility.currentState == visibility.targetState &&
                                visibility.targetState != EntryVisibility.Shown
                        }
                        .collect { isRemoved ->
                            if (isRemoved) {
                                visiblePinEntries.remove(entry)
                            }
                        }
                }
            }
        }
    }
}

private sealed class EntryVisibility {
    object Shown : EntryVisibility()

    object Hidden : EntryVisibility()

    /**
     * Same as [Hidden], but applies when multiple entries are hidden simultaneously, without
     * collapsing during the hide.
     */
    data class BulkHidden(val staggerIndex: Int, val totalEntryCount: Int) : EntryVisibility()
}

@Composable
private fun ObscuredInputEntry(transition: Transition<EntryVisibility>) {
    // spec: http://shortn/_DEhE3Xl2bi
    val shapePadding = 6.dp
    val shapeOvershootSize = 22.dp
    val dismissStaggerDelayMs = 33
    val dismissDurationMs = 450
    val expansionDurationMs = 250
    val shapeExpandDurationMs = 83
    val shapeRetractDurationMs = 167
    val shapeCollapseDurationMs = 200

    val animatedEntryWidth by
        transition.animateDp(
            transitionSpec = {
                when (val target = targetState) {
                    is EntryVisibility.BulkHidden ->
                        // only collapse horizontal space once all entries are removed
                        snap(dismissDurationMs + dismissStaggerDelayMs * target.totalEntryCount)
                    else -> tween(expansionDurationMs, easing = Easings.Standard)
                }
            },
            label = "entry space"
        ) { state ->
            if (state == EntryVisibility.Shown) entryShapeSize + (shapePadding * 2) else 0.dp
        }

    val animatedShapeSize by
        transition.animateDp(
            transitionSpec = {
                when {
                    EntryVisibility.Hidden isTransitioningTo EntryVisibility.Shown ->
                        keyframes {
                            durationMillis = shapeExpandDurationMs + shapeRetractDurationMs
                            0.dp at 0 with Easings.Linear
                            shapeOvershootSize at shapeExpandDurationMs with Easings.Legacy
                        }
                    targetState is EntryVisibility.BulkHidden -> {
                        val target = targetState as EntryVisibility.BulkHidden
                        tween(
                            dismissDurationMs,
                            delayMillis = target.staggerIndex * dismissStaggerDelayMs,
                            easing = Easings.Legacy,
                        )
                    }
                    else -> tween(shapeCollapseDurationMs, easing = Easings.StandardDecelerate)
                }
            },
            label = "shape size"
        ) { state ->
            when (state) {
                EntryVisibility.Shown -> entryShapeSize
                else -> 0.dp
            }
        }

    val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
    Layout(
        content = {
            // TODO(b/282730134): add support for dot shapes.
            Canvas(Modifier) { drawCircle(dotColor) }
        }
    ) { measurables, _ ->
        val shapeSizePx = animatedShapeSize.roundToPx()
        val placeable = measurables.single().measure(Constraints.fixed(shapeSizePx, shapeSizePx))

        layout(animatedEntryWidth.roundToPx(), entryShapeSize.roundToPx()) {
            placeable.place(
                ((animatedEntryWidth - animatedShapeSize) / 2f).roundToPx(),
                ((entryShapeSize - animatedShapeSize) / 2f).roundToPx()
            )
        }
    }
}

@Composable
private fun PinDigit(
    digit: Int,
@@ -310,11 +428,13 @@ private fun showFailureAnimation() {
    // TODO(b/282730134): implement.
}

private val entryShapeSize = 16.dp

private val pinButtonSize = 84.dp

// Pin button motion spec: http://shortn/_9TTIG6SoEa
private val pinButtonPressedDuration = 100.milliseconds
private val pinButtonPressedEasing = LinearEasing
private val pinButtonPressedEasing = Easings.Linear
private val pinButtonHoldTime = 33.milliseconds
private val pinButtonReleasedDuration = 420.milliseconds
private val pinButtonReleasedEasing = Easings.Standard
+24 −44
Original line number Diff line number Diff line
@@ -16,18 +16,10 @@

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

import androidx.annotation.VisibleForTesting
import com.android.systemui.bouncer.domain.interactor.BouncerInteractor
import com.android.systemui.util.kotlin.pairwise
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/** Holds UI state and handles user input for the PIN code bouncer UI. */
class PinBouncerViewModel(
@@ -39,21 +31,8 @@ class PinBouncerViewModel(
        isInputEnabled = isInputEnabled,
    ) {

    private val entered = MutableStateFlow<List<Int>>(emptyList())
    /**
     * The length of the PIN digits that were input so far, two values are supplied the previous and
     * the current.
     */
    val pinLengths: StateFlow<Pair<Int, Int>> =
        entered
            .pairwise()
            .map { it.previousValue.size to it.newValue.size }
            .stateIn(
                scope = applicationScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = 0 to 0,
            )
    private var resetPinJob: Job? = null
    private val mutablePinEntries = MutableStateFlow<List<EnteredKey>>(emptyList())
    val pinEntries: StateFlow<List<EnteredKey>> = mutablePinEntries

    /** Notifies that the UI has been shown to the user. */
    fun onShown() {
@@ -62,47 +41,48 @@ class PinBouncerViewModel(

    /** Notifies that the user clicked on a PIN button with the given digit value. */
    fun onPinButtonClicked(input: Int) {
        resetPinJob?.cancel()
        resetPinJob = null

        if (entered.value.isEmpty()) {
        if (mutablePinEntries.value.isEmpty()) {
            interactor.clearMessage()
        }

        entered.value += input
        mutablePinEntries.value += EnteredKey(input)
    }

    /** Notifies that the user clicked the backspace button. */
    fun onBackspaceButtonClicked() {
        if (entered.value.isEmpty()) {
        if (mutablePinEntries.value.isEmpty()) {
            return
        }

        entered.value = entered.value.toMutableList().apply { removeLast() }
        mutablePinEntries.value = mutablePinEntries.value.toMutableList().apply { removeLast() }
    }

    /** Notifies that the user long-pressed the backspace button. */
    fun onBackspaceButtonLongPressed() {
        resetPinJob?.cancel()
        resetPinJob =
            applicationScope.launch {
                while (entered.value.isNotEmpty()) {
                    onBackspaceButtonClicked()
                    delay(BACKSPACE_LONG_PRESS_DELAY_MS)
                }
            }
        mutablePinEntries.value = emptyList()
    }

    /** Notifies that the user clicked the "enter" button. */
    fun onAuthenticateButtonClicked() {
        if (!interactor.authenticate(entered.value)) {
        if (!interactor.authenticate(mutablePinEntries.value.map { it.input })) {
            showFailureAnimation()
        }

        entered.value = emptyList()
        mutablePinEntries.value = emptyList()
    }

    companion object {
        @VisibleForTesting const val BACKSPACE_LONG_PRESS_DELAY_MS = 80L
}

private var nextSequenceNumber = 1

/**
 * The pin bouncer [input] as digits 0-9, together with a [sequenceNumber] to indicate the ordering.
 *
 * Since the model only allows appending/removing [EnteredKey]s from the end, the [sequenceNumber]
 * is strictly increasing in input order of the pin, but not guaranteed to be monotonic or start at
 * a specific number.
 */
data class EnteredKey
internal constructor(val input: Int, val sequenceNumber: Int = nextSequenceNumber++) :
    Comparable<EnteredKey> {
    override fun compareTo(other: EnteredKey): Int =
        compareValuesBy(this, other, EnteredKey::sequenceNumber)
}
+48 −18
Original line number Diff line number Diff line
@@ -25,12 +25,12 @@ import com.android.systemui.coroutines.collectLastValue
import com.android.systemui.scene.SceneTestUtils
import com.android.systemui.scene.shared.model.SceneKey
import com.android.systemui.scene.shared.model.SceneModel
import com.google.common.truth.Correspondence
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
@@ -85,7 +85,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -95,7 +95,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            underTest.onShown()

            assertThat(message?.text).isEqualTo(ENTER_YOUR_PIN)
            assertThat(pinLengths).isEqualTo(0 to 0)
            assertThat(entries).hasSize(0)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
        }
@@ -106,7 +106,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -117,7 +117,8 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            underTest.onPinButtonClicked(1)

            assertThat(message?.text).isEmpty()
            assertThat(pinLengths).isEqualTo(0 to 1)
            assertThat(entries).hasSize(1)
            assertThat(entries?.map { it.input }).containsExactly(1)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
        }
@@ -128,7 +129,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -136,23 +137,50 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
            underTest.onShown()
            underTest.onPinButtonClicked(1)
            assertThat(pinLengths).isEqualTo(0 to 1)
            assertThat(entries).hasSize(1)

            underTest.onBackspaceButtonClicked()

            assertThat(message?.text).isEmpty()
            assertThat(pinLengths).isEqualTo(1 to 0)
            assertThat(entries).hasSize(0)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
        }

    @Test
    fun onPinEdit() =
        testScope.runTest {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
            underTest.onShown()

            underTest.onPinButtonClicked(1)
            underTest.onPinButtonClicked(2)
            underTest.onPinButtonClicked(3)
            underTest.onBackspaceButtonClicked()
            underTest.onBackspaceButtonClicked()
            underTest.onPinButtonClicked(4)
            underTest.onPinButtonClicked(5)

            assertThat(entries).hasSize(3)
            assertThat(entries?.map { it.input }).containsExactly(1, 4, 5).inOrder()
            assertThat(entries?.map { it.sequenceNumber }).isInStrictOrder()
        }

    @Test
    fun onBackspaceButtonLongPressed() =
        testScope.runTest {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -165,13 +193,9 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            underTest.onPinButtonClicked(4)

            underTest.onBackspaceButtonLongPressed()
            repeat(4) { index ->
                assertThat(pinLengths).isEqualTo(4 - index to 3 - index)
                advanceTimeBy(PinBouncerViewModel.BACKSPACE_LONG_PRESS_DELAY_MS)
            }

            assertThat(message?.text).isEmpty()
            assertThat(pinLengths).isEqualTo(1 to 0)
            assertThat(entries).hasSize(0)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
        }
@@ -204,7 +228,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -219,7 +243,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {

            underTest.onAuthenticateButtonClicked()

            assertThat(pinLengths).isEqualTo(0 to 0)
            assertThat(entries).hasSize(0)
            assertThat(message?.text).isEqualTo(WRONG_PIN)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))
@@ -231,7 +255,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked)
            val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME))
            val message by collectLastValue(bouncerViewModel.message)
            val pinLengths by collectLastValue(underTest.pinLengths)
            val entries by collectLastValue(underTest.pinEntries)
            authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234))
            authenticationInteractor.lockDevice()
            sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer))
@@ -245,7 +269,7 @@ class PinBouncerViewModelTest : SysuiTestCase() {
            underTest.onPinButtonClicked(5) // PIN is now wrong!
            underTest.onAuthenticateButtonClicked()
            assertThat(message?.text).isEqualTo(WRONG_PIN)
            assertThat(pinLengths).isEqualTo(0 to 0)
            assertThat(entries).hasSize(0)
            assertThat(isUnlocked).isFalse()
            assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer))

@@ -266,5 +290,11 @@ class PinBouncerViewModelTest : SysuiTestCase() {
        private const val CONTAINER_NAME = "container1"
        private const val ENTER_YOUR_PIN = "Enter your pin"
        private const val WRONG_PIN = "Wrong pin"

        val KEY_CODE =
            Correspondence.transforming<EnteredKey, Int>(
                { it?.input },
                "has a eventId of",
            )
    }
}