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

Commit 3a063c6e authored by Mike Schneider's avatar Mike Schneider Committed by Automerger Merge Worker
Browse files

Merge "Implement auto-confirm hinting" into udc-qpr-dev am: 50051381 am: f38a0428

parents 6fabc988 f38a0428
Loading
Loading
Loading
Loading
+0 −167
Original line number Diff line number Diff line
@@ -14,63 +14,40 @@
 * limitations under the License.
 */

@file:OptIn(ExperimentalAnimationApi::class, ExperimentalAnimationGraphicsApi::class)

package com.android.systemui.bouncer.ui.composable

import android.view.HapticFeedbackConstants
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
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.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.graphics.ExperimentalAnimationGraphicsApi
import androidx.compose.animation.graphics.res.animatedVectorResource
import androidx.compose.animation.graphics.res.rememberAnimatedVectorPainter
import androidx.compose.animation.graphics.vector.AnimatedImageVector
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.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.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
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
@@ -78,7 +55,6 @@ import com.android.compose.grid.VerticalGrid
import com.android.compose.modifiers.thenIf
import com.android.systemui.R
import com.android.systemui.bouncer.ui.viewmodel.ActionButtonAppearance
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
@@ -108,147 +84,6 @@ 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
                    }

                val shape = viewModel.pinShapes.getShape(entry.sequenceNumber)
                PinInputEntry(shape, 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 PinInputEntry(shapeResourceId: Int, transition: Transition<EntryVisibility>) {
    // spec: http://shortn/_DEhE3Xl2bi
    val dismissStaggerDelayMs = 33
    val dismissDurationMs = 450
    val expansionDurationMs = 250
    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 else 0.dp
        }

    val animatedShapeSize by
        transition.animateDp(
            transitionSpec = {
                when {
                    EntryVisibility.Hidden isTransitioningTo EntryVisibility.Shown -> {
                        // The AVD contains the entry transition.
                        snap()
                    }
                    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 ->
            if (state == EntryVisibility.Shown) entryShapeSize else 0.dp
        }

    val dotColor = MaterialTheme.colorScheme.onSurfaceVariant
    Layout(
        content = {
            val image = AnimatedImageVector.animatedVectorResource(shapeResourceId)
            var atEnd by remember { mutableStateOf(false) }
            Image(
                painter = rememberAnimatedVectorPainter(image, atEnd),
                contentDescription = null,
                contentScale = ContentScale.Crop,
                colorFilter = ColorFilter.tint(dotColor),
            )
            LaunchedEffect(Unit) { atEnd = true }
        }
    ) { 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 PinPad(viewModel: PinBouncerViewModel) {
    val isInputEnabled: Boolean by viewModel.isInputEnabled.collectAsState()
@@ -511,8 +346,6 @@ private suspend fun showFailureAnimation(
    }
}

private val entryShapeSize = 30.dp

private val pinButtonSize = 84.dp
private val pinButtonErrorShrinkFactor = 67.dp / pinButtonSize
private const val pinButtonErrorShrinkMs = 50
+437 −0

File added.

Preview size limit exceeded, changes collapsed.

+16 −32
Original line number Diff line number Diff line
@@ -40,9 +40,10 @@ class PinBouncerViewModel(
    ) {

    val pinShapes = PinShapeAdapter(applicationContext)
    private val mutablePinInput = MutableStateFlow(PinInputViewModel.empty())

    private val mutablePinEntries = MutableStateFlow<List<EnteredKey>>(emptyList())
    val pinEntries: StateFlow<List<EnteredKey>> = mutablePinEntries
    /** Currently entered pin keys. */
    val pinInput: StateFlow<PinInputViewModel> = mutablePinInput

    /** The length of the PIN for which we should show a hint. */
    val hintedPinLength: StateFlow<Int?> = interactor.hintedPinLength
@@ -50,11 +51,11 @@ class PinBouncerViewModel(
    /** Appearance of the backspace button. */
    val backspaceButtonAppearance: StateFlow<ActionButtonAppearance> =
        combine(
                mutablePinEntries,
                mutablePinInput,
                interactor.isAutoConfirmEnabled,
            ) { mutablePinEntries, isAutoConfirmEnabled ->
                computeBackspaceButtonAppearance(
                    enteredPin = mutablePinEntries,
                    pinInput = mutablePinEntries,
                    isAutoConfirmEnabled = isAutoConfirmEnabled,
                )
            }
@@ -89,26 +90,23 @@ class PinBouncerViewModel(

    /** Notifies that the user clicked on a PIN button with the given digit value. */
    fun onPinButtonClicked(input: Int) {
        if (mutablePinEntries.value.isEmpty()) {
        val pinInput = mutablePinInput.value
        if (pinInput.isEmpty()) {
            interactor.clearMessage()
        }

        mutablePinEntries.value += EnteredKey(input)

        mutablePinInput.value = pinInput.append(input)
        tryAuthenticate(useAutoConfirm = true)
    }

    /** Notifies that the user clicked the backspace button. */
    fun onBackspaceButtonClicked() {
        if (mutablePinEntries.value.isEmpty()) {
            return
        }
        mutablePinEntries.value = mutablePinEntries.value.toMutableList().apply { removeLast() }
        mutablePinInput.value = mutablePinInput.value.deleteLast()
    }

    /** Notifies that the user long-pressed the backspace button. */
    fun onBackspaceButtonLongPressed() {
        mutablePinEntries.value = emptyList()
        mutablePinInput.value = mutablePinInput.value.clearAll()
    }

    /** Notifies that the user clicked the "enter" button. */
@@ -117,7 +115,7 @@ class PinBouncerViewModel(
    }

    private fun tryAuthenticate(useAutoConfirm: Boolean) {
        val pinCode = mutablePinEntries.value.map { it.input }
        val pinCode = mutablePinInput.value.getPin()

        applicationScope.launch {
            val isSuccess = interactor.authenticate(pinCode, useAutoConfirm) ?: return@launch
@@ -126,15 +124,17 @@ class PinBouncerViewModel(
                showFailureAnimation()
            }

            mutablePinEntries.value = emptyList()
            // TODO(b/291528545): this should not be cleared on success (at least until the view
            // is animated away).
            mutablePinInput.value = mutablePinInput.value.clearAll()
        }
    }

    private fun computeBackspaceButtonAppearance(
        enteredPin: List<EnteredKey>,
        pinInput: PinInputViewModel,
        isAutoConfirmEnabled: Boolean,
    ): ActionButtonAppearance {
        val isEmpty = enteredPin.isEmpty()
        val isEmpty = pinInput.isEmpty()

        return when {
            isAutoConfirmEnabled && isEmpty -> ActionButtonAppearance.Hidden
@@ -153,19 +153,3 @@ enum class ActionButtonAppearance {
    /** Button is shown. */
    Shown,
}

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)
}
+177 −0
Original line number Diff line number Diff line
/*
 * 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.annotation.VisibleForTesting
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.ClearAll
import com.android.systemui.bouncer.ui.viewmodel.EntryToken.Digit

/**
 * Immutable pin input state.
 *
 * The input is a hybrid of state ([Digit]) and event ([ClearAll]) tokens. The [ClearAll] token can
 * be interpreted as a watermark, indicating that the current input up to that point is deleted
 * (after a auth failure or when long-pressing the delete button). Therefore, [Digit]s following a
 * [ClearAll] make up the next pin input entry. Up to two complete pin inputs are memoized.
 *
 * This is required when auto-confirm rejects the input, and the last digit will be animated-in at
 * the end of the input, concurrently with the staggered clear-all animation starting to play at the
 * beginning of the input.
 *
 * The input is guaranteed to always contain a initial [ClearAll] token as a sentinel, thus clients
 * can always assume there is a 'ClearAll' watermark available.
 */
data class PinInputViewModel
internal constructor(
    @get:VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
    internal val input: List<EntryToken>
) {
    init {
        require(input.firstOrNull() is ClearAll) { "input does not begin with a ClearAll token" }
        require(input.zipWithNext().all { it.first < it.second }) {
            "EntryTokens are not sorted by their sequenceNumber"
        }
    }
    /**
     * [PinInputViewModel] with [previousInput] and appended [newToken].
     *
     * [previousInput] is trimmed so that the new [PinBouncerViewModel] contains at most two pin
     * inputs.
     */
    private constructor(
        previousInput: List<EntryToken>,
        newToken: EntryToken
    ) : this(
        buildList {
            addAll(
                previousInput.subList(previousInput.indexOfLastClearAllToKeep(), previousInput.size)
            )
            add(newToken)
        }
    )

    fun append(digit: Int): PinInputViewModel {
        return PinInputViewModel(input, Digit(digit))
    }

    /**
     * Delete last digit.
     *
     * This removes the last digit from the input. Returns `this` if the last token is [ClearAll].
     */
    fun deleteLast(): PinInputViewModel {
        if (isEmpty()) return this
        return PinInputViewModel(input.take(input.size - 1))
    }

    /**
     * Appends a [ClearAll] watermark, completing the current pin.
     *
     * Returns `this` if the last token is [ClearAll].
     */
    fun clearAll(): PinInputViewModel {
        if (isEmpty()) return this
        return PinInputViewModel(input, ClearAll())
    }

    /** Whether the current pin is empty. */
    fun isEmpty(): Boolean {
        return input.last() is ClearAll
    }

    /** The current pin, or an empty list if [isEmpty]. */
    fun getPin(): List<Int> {
        return getDigits(mostRecentClearAll()).map { it.input }
    }

    /**
     * The digits following the specified [ClearAll] marker, up to the next marker or the end of the
     * input.
     *
     * Returns an empty list if the [ClearAll] is not in the input.
     */
    fun getDigits(clearAllMarker: ClearAll): List<Digit> {
        val startIndex = input.indexOf(clearAllMarker) + 1
        if (startIndex == 0 || startIndex == input.size) return emptyList()

        return input.subList(startIndex, input.size).takeWhile { it is Digit }.map { it as Digit }
    }

    /** The most recent [ClearAll] marker. */
    fun mostRecentClearAll(): ClearAll {
        return input.last { it is ClearAll } as ClearAll
    }

    companion object {
        fun empty() = PinInputViewModel(listOf(ClearAll()))
    }
}

/**
 * Pin bouncer entry token with a [sequenceNumber] to indicate input event ordering.
 *
 * Since the model only allows appending/removing [Digit]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.
 */
sealed interface EntryToken : Comparable<EntryToken> {
    val sequenceNumber: Int

    /** The pin bouncer [input] as digits 0-9. */
    data class Digit
    internal constructor(val input: Int, override val sequenceNumber: Int = nextSequenceNumber++) :
        EntryToken {
        init {
            check(input in 0..9)
        }
    }

    /**
     * Marker to indicate the input is completely cleared, and subsequent [EntryToken]s mark a new
     * pin entry.
     */
    data class ClearAll
    internal constructor(override val sequenceNumber: Int = nextSequenceNumber++) : EntryToken

    override fun compareTo(other: EntryToken): Int =
        compareValuesBy(this, other, EntryToken::sequenceNumber)

    companion object {
        private var nextSequenceNumber = 1
    }
}

/**
 * Index of the last [ClearAll] token to keep for a new [PinInputViewModel], so that after appending
 * another [EntryToken], there are at most two pin inputs in the [PinInputViewModel].
 */
private fun List<EntryToken>.indexOfLastClearAllToKeep(): Int {
    require(isNotEmpty() && first() is ClearAll)

    var seenClearAll = 0
    for (i in size - 1 downTo 0) {
        if (get(i) is ClearAll) {
            seenClearAll++
            if (seenClearAll == 2) {
                return i
            }
        }
    }

    // The first element is guaranteed to be a ClearAll marker.
    return 0
}
+18 −20

File changed.

Preview size limit exceeded, changes collapsed.

Loading