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 853300cf95081..c6c30199a2273 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 @@ -16,18 +16,30 @@ package com.android.systemui.bouncer.ui.composable +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +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.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue 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.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel @@ -55,26 +67,69 @@ constructor( ) .asStateFlow() - @Composable - override fun Content( - modifier: Modifier, - ) { - // TODO(b/280877228): implement the real UI. + @Composable override fun Content(modifier: Modifier) = BouncerScene(viewModel, modifier) +} - Box(modifier = modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.Center) - ) { - Text("Bouncer", style = MaterialTheme.typography.headlineLarge) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { viewModel.onAuthenticateButtonClicked() }) { - Text("Authenticate") - } - } +@Composable +private fun BouncerScene( + viewModel: BouncerViewModel, + modifier: Modifier = Modifier, +) { + val message: String by viewModel.message.collectAsState() + val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(60.dp), + modifier = + modifier.background(MaterialTheme.colorScheme.surface).fillMaxSize().padding(32.dp) + ) { + Crossfade( + targetState = message, + label = "Bouncer message", + ) { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Box(Modifier.weight(1f)) { + when (val nonNullViewModel = authMethodViewModel) { + is PinBouncerViewModel -> + PinBouncer( + viewModel = nonNullViewModel, + modifier = Modifier.align(Alignment.Center), + ) + is PasswordBouncerViewModel -> + PasswordBouncer( + viewModel = nonNullViewModel, + modifier = Modifier.align(Alignment.Center), + ) + is PatternBouncerViewModel -> + PatternBouncer( + viewModel = nonNullViewModel, + modifier = + Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false) + .align(Alignment.BottomCenter), + ) + else -> Unit } } + + Button( + onClick = viewModel::onEmergencyServicesButtonClicked, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Text( + text = stringResource(com.android.internal.R.string.lockscreen_emergency_call), + style = MaterialTheme.typography.bodyMedium, + ) + } } } 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 new file mode 100644 index 0000000000000..4e85621e9e233 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -0,0 +1,99 @@ +/* + * 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.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel + +/** UI for the input part of a password-requiring version of the bouncer. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PasswordBouncer( + viewModel: PasswordBouncerViewModel, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val password: String by viewModel.password.collectAsState() + + LaunchedEffect(Unit) { + // When the UI comes up, request focus on the TextField to bring up the software keyboard. + focusRequester.requestFocus() + // Also, report that the UI is shown to let the view-model runs some logic. + viewModel.onShown() + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + val lineWidthPx = with(LocalDensity.current) { 2.dp.toPx() } + + TextField( + value = password, + onValueChange = viewModel::onPasswordInputChanged, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { viewModel.onAuthenticateKeyPressed() }, + ), + modifier = + Modifier.focusRequester(focusRequester).drawBehind { + drawLine( + color = color, + start = Offset(x = 0f, y = size.height - lineWidthPx), + end = Offset(size.width, y = size.height - lineWidthPx), + strokeWidth = lineWidthPx, + ) + }, + ) + + Spacer(Modifier.height(100.dp)) + } +} 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 new file mode 100644 index 0000000000000..383c748f5dd8f --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -0,0 +1,281 @@ +/* + * 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.composable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt +import kotlinx.coroutines.launch + +/** + * UI for the input part of a pattern-requiring version of the bouncer. + * + * The user can press, hold, and drag their pointer to select dots along a grid of dots. + */ +@Composable +internal fun PatternBouncer( + viewModel: PatternBouncerViewModel, + modifier: Modifier = Modifier, +) { + // Report that the UI is shown to let the view-model run some logic. + LaunchedEffect(Unit) { viewModel.onShown() } + + val colCount = viewModel.columnCount + val rowCount = viewModel.rowCount + + val dotColor = MaterialTheme.colorScheme.secondary + val dotRadius = with(LocalDensity.current) { 8.dp.toPx() } + val lineColor = MaterialTheme.colorScheme.primary + val lineStrokeWidth = dotRadius * 2 + with(LocalDensity.current) { 4.dp.toPx() } + + var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) } + val horizontalSpacing = containerSize.width / colCount + val verticalSpacing = containerSize.height / rowCount + val spacing = min(horizontalSpacing, verticalSpacing).toFloat() + val verticalOffset = containerSize.height - spacing * rowCount + + // All dots that should be rendered on the grid. + val dots: List by viewModel.dots.collectAsState() + // The most recently selected dot, if the user is currently dragging. + 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() + + // Map of animatables for the scale of each dot, keyed by dot. + val scales = remember(dots) { dots.associateWith { Animatable(1f) } } + // Map of animatables for the lines that connect between selected dots, keyed by the destination + // dot of the line. + val lines = remember(dots) { dots.associateWith { Animatable(1f) } } + + val scope = rememberCoroutineScope() + + // When the current dot is changed, we need to update our animations. + LaunchedEffect(currentDot) { + // Make sure that the current dot is scaled up while the other dots are scaled back down. + scales.entries.forEach { (dot, animatable) -> + val isSelected = dot == currentDot + launch { + animatable.animateTo(if (isSelected) 2f else 1f) + if (isSelected) { + animatable.animateTo(1f) + } + } + } + + // Make sure that all dot-connecting lines are decaying, if they're not already animating. + selectedDots.forEach { + lines[it]?.let { line -> + if (!line.isRunning) { + scope.launch { + line.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + } + } + + // This is the position of the input pointer. + var inputPosition: Offset? by remember { mutableStateOf(null) } + + Canvas( + modifier + // Need to clip to bounds to make sure that the lines don't follow the input pointer + // 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 + lines.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, + ) + } + } + ) { + // Draw lines between dots. + selectedDots.forEachIndexed { index, dot -> + if (index > 0) { + val previousDot = selectedDots[index - 1] + drawLine( + from = previousDot, + to = dot, + alpha = { distance -> lineAlpha(spacing, distance) }, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) + } + } + + // Draw the line between the most recently-selected dot and the input pointer position. + inputPosition?.let { lineEnd -> + currentDot?.let { dot -> + drawLine( + from = dot, + to = lineEnd, + alpha = { distance -> lineAlpha(spacing, distance) }, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) + } + } + + // Draw each dot on the grid. + dots.forEach { dot -> + drawDot( + dot = dot, + scaleFactor = { scales[dot]?.value ?: 1f }, + spacing = spacing, + verticalOffset = verticalOffset, + dotColor = dotColor, + dotRadius = dotRadius, + ) + } + } +} + +/** Draws the given [dot]. */ +private fun DrawScope.drawDot( + dot: PatternDotViewModel, + scaleFactor: () -> Float, + spacing: Float, + verticalOffset: Float, + dotColor: Color, + dotRadius: Float, +) { + drawCircle( + color = dotColor, + radius = dotRadius * scaleFactor.invoke(), + center = pixelOffset(dot, spacing, verticalOffset), + ) +} + +/** Draws a line from the [from] origin dot to the [to] destination dot. */ +private fun DrawScope.drawLine( + from: PatternDotViewModel, + to: PatternDotViewModel, + alpha: (distance: Float) -> Float, + spacing: Float, + verticalOffset: Float, + lineColor: Color, + lineStrokeWidth: Float, +) { + drawLine( + from = from, + to = pixelOffset(to, spacing, verticalOffset), + alpha = alpha, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) +} + +/** Draws a line from the [from] origin dot to the [to] destination. */ +private fun DrawScope.drawLine( + from: PatternDotViewModel, + to: Offset, + alpha: (distance: Float) -> Float, + spacing: Float, + verticalOffset: Float, + lineColor: Color, + lineStrokeWidth: Float, +) { + val fromAsOffset = pixelOffset(from, spacing, verticalOffset) + val distance = sqrt((to.y - fromAsOffset.y).pow(2) + (to.x - fromAsOffset.x).pow(2)) + + drawLine( + color = lineColor, + start = fromAsOffset, + end = to, + strokeWidth = lineStrokeWidth, + cap = StrokeCap.Round, + alpha = alpha.invoke(distance), + ) +} + +/** Returns an [Offset] representation of the given [dot] in pixel coordinates. */ +private fun pixelOffset( + dot: PatternDotViewModel, + spacing: Float, + verticalOffset: Float, +): Offset { + return Offset( + x = dot.x * spacing + spacing / 2, + y = dot.y * spacing + spacing / 2 + verticalOffset, + ) +} + +/** + * Returns the alpha for a line between dots where dots are [spacing] apart from each other on the + * dot grid and the line ends [distance] away from the origin dot. + * + * The reason [distance] can be different from [spacing] is that all lines originate in dots but one + * line might end where the user input pointer is, which isn't always a dot position. + */ +private fun lineAlpha(spacing: Float, distance: Float): Float { + // Custom curve for the alpha of a line as a function of its distance from its source dot. The + // farther the user input pointer goes from the line, the more opaque the line gets. + return ((distance / spacing - 0.3f) * 4f).coerceIn(0f, 1f) +} 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 new file mode 100644 index 0000000000000..9c210c225ab39 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -0,0 +1,247 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalAnimationApi::class) + +package com.android.systemui.bouncer.ui.composable + +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.animateDpAsState +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.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 +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.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.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.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.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.compose.grid.VerticalGrid +import com.android.systemui.R +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 kotlin.math.max + +@Composable +internal fun PinBouncer( + viewModel: PinBouncerViewModel, + modifier: Modifier = Modifier, +) { + // 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 by viewModel.pinLengths.collectAsState() + + Column( + 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, + ) + ) + } + } + } + + Spacer(Modifier.height(100.dp)) + + VerticalGrid( + columns = 3, + verticalSpacing = 12.dp, + horizontalSpacing = 20.dp, + ) { + repeat(9) { index -> + val digit = index + 1 + PinButton( + onClicked = { viewModel.onPinButtonClicked(digit) }, + ) { contentColor -> + PinDigit(digit, contentColor) + } + } + + PinButton( + onClicked = { viewModel.onBackspaceButtonClicked() }, + onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, + isHighlighted = true, + ) { contentColor -> + PinIcon( + Icon.Resource( + res = R.drawable.ic_backspace_24dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_delete), + ), + contentColor, + ) + } + + PinButton( + onClicked = { viewModel.onPinButtonClicked(0) }, + ) { contentColor -> + PinDigit(0, contentColor) + } + + PinButton( + onClicked = { viewModel.onAuthenticateButtonClicked() }, + isHighlighted = true, + ) { contentColor -> + PinIcon( + Icon.Resource( + res = R.drawable.ic_keyboard_tab_36dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_enter), + ), + contentColor, + ) + } + } + } +} + +@Composable +private fun PinDigit( + digit: Int, + contentColor: Color, +) { + // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes it + // into Text, use that here, to animate more efficiently. + Text( + text = digit.toString(), + style = MaterialTheme.typography.headlineLarge, + color = contentColor, + ) +} + +@Composable +private fun PinIcon( + icon: Icon, + contentColor: Color, +) { + Icon( + icon = icon, + tint = contentColor, + ) +} + +@Composable +private fun PinButton( + onClicked: () -> Unit, + modifier: Modifier = Modifier, + onLongPressed: (() -> Unit)? = null, + isHighlighted: Boolean = false, + content: @Composable (contentColor: Color) -> Unit, +) { + var isPressed: Boolean by remember { mutableStateOf(false) } + val cornerRadius: Dp by + animateDpAsState( + if (isPressed) 24.dp else PinButtonSize / 2, + label = "PinButton round corners", + ) + val containerColor: Color by + animateColorAsState( + when { + isPressed -> MaterialTheme.colorScheme.primaryContainer + isHighlighted -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + }, + label = "Pin button container color", + ) + val contentColor: Color by + animateColorAsState( + when { + isPressed -> MaterialTheme.colorScheme.onPrimaryContainer + isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + }, + label = "Pin button container color", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .size(PinButtonSize) + .drawBehind { + drawRoundRect( + color = containerColor, + cornerRadius = CornerRadius(cornerRadius.toPx()), + ) + } + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + tryAwaitRelease() + isPressed = false + }, + onTap = { onClicked() }, + onLongPress = onLongPressed?.let { { onLongPressed() } }, + ) + }, + ) { + content(contentColor) + } +} + +private val PinButtonSize = 84.dp 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 new file mode 100644 index 0000000000000..ebefb78f0477d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -0,0 +1,19 @@ +/* + * 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 + +sealed interface AuthMethodBouncerViewModel 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 8a183ae6ca7ce..eaa8ed5b358ef 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 @@ -16,6 +16,7 @@ package com.android.systemui.bouncer.ui.viewmodel +import android.content.Context import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.dagger.qualifiers.Application @@ -30,12 +31,44 @@ import kotlinx.coroutines.flow.stateIn class BouncerViewModel @AssistedInject constructor( + @Application private val applicationContext: Context, @Application private val applicationScope: CoroutineScope, interactorFactory: BouncerInteractor.Factory, containerName: String, ) { private val interactor: BouncerInteractor = interactorFactory.create(containerName) + private val pin: PinBouncerViewModel by lazy { + PinBouncerViewModel( + applicationScope = applicationScope, + interactor = interactor, + ) + } + + private val password: PasswordBouncerViewModel by lazy { + PasswordBouncerViewModel( + interactor = interactor, + ) + } + + private val pattern: PatternBouncerViewModel by lazy { + PatternBouncerViewModel( + applicationContext = applicationContext, + applicationScope = applicationScope, + interactor = interactor, + ) + } + + /** View-model for the current UI, based on the current authentication method. */ + val authMethod: StateFlow = + interactor.authenticationMethod + .map { authMethod -> toViewModel(authMethod) } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = toViewModel(interactor.authenticationMethod.value), + ) + /** The user-facing message to show in the bouncer. */ val message: StateFlow = interactor.message @@ -46,30 +79,19 @@ constructor( initialValue = interactor.message.value ?: "", ) - /** Notifies that the authenticate button was clicked. */ - fun onAuthenticateButtonClicked() { - // TODO(b/280877228): remove this and send the real input. - interactor.authenticate( - when (interactor.authenticationMethod.value) { - is AuthenticationMethodModel.PIN -> listOf(1, 2, 3, 4) - is AuthenticationMethodModel.Password -> "password".toList() - is AuthenticationMethodModel.Pattern -> - listOf( - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 0), - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 2), - AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 0), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 2), - ) - else -> emptyList() - } - ) - } - /** Notifies that the emergency services button was clicked. */ fun onEmergencyServicesButtonClicked() { - // TODO(b/280877228): implement this. + // TODO(b/280877228): implement this + } + + private fun toViewModel( + authMethod: AuthenticationMethodModel, + ): AuthMethodBouncerViewModel? { + return when (authMethod) { + is AuthenticationMethodModel.PIN -> pin + is AuthenticationMethodModel.Password -> password + is AuthenticationMethodModel.Pattern -> pattern + else -> null + } } } 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 new file mode 100644 index 0000000000000..730d4e8ba0503 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.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.bouncer.ui.viewmodel + +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Holds UI state and handles user input for the password bouncer UI. */ +class PasswordBouncerViewModel( + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + private val _password = MutableStateFlow("") + /** The password entered so far. */ + val password: StateFlow = _password.asStateFlow() + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** Notifies that the user has changed the password input. */ + fun onPasswordInputChanged(password: String) { + if (this.password.value.isEmpty() && password.isNotEmpty()) { + interactor.clearMessage() + } + + _password.value = password + } + + /** Notifies that the user has pressed the key for attempting to authenticate the password. */ + fun onAuthenticateKeyPressed() { + interactor.authenticate(password.value.toCharArray().toList()) + _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 new file mode 100644 index 0000000000000..eb1b45771ad45 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -0,0 +1,189 @@ +/* + * 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 android.content.Context +import android.util.TypedValue +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Holds UI state and handles user input for the pattern bouncer UI. */ +class PatternBouncerViewModel( + private val applicationContext: Context, + applicationScope: CoroutineScope, + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + /** The number of columns in the dot grid. */ + val columnCount = 3 + /** The number of rows in the dot grid. */ + val rowCount = 3 + + private val _selectedDots = MutableStateFlow>(linkedSetOf()) + /** The dots that were selected by the user, in the order of selection. */ + val selectedDots: StateFlow> = + _selectedDots + .map { it.toList() } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList(), + ) + + private val _currentDot = MutableStateFlow(null) + /** The most-recently selected dot that the user selected. */ + val currentDot: StateFlow = _currentDot.asStateFlow() + + private val _dots = MutableStateFlow(defaultDots()) + /** All dots on the grid. */ + val dots: StateFlow> = _dots.asStateFlow() + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** Notifies that the user has started a drag gesture across the dot grid. */ + fun onDragStart() { + interactor.clearMessage() + } + + /** + * Notifies that the user is dragging across the dot grid. + * + * @param xPx The horizontal coordinate of the position of the user's pointer, in pixels. + * @param yPx The vertical coordinate of the position of the user's pointer, in pixels. + * @param containerSizePx The size of the container of the dot grid, in pixels. It's assumed + * that the dot grid is perfectly square such that width and height are equal. + * @param verticalOffsetPx How far down from `0` does the dot grid start on the display. + */ + fun onDrag(xPx: Float, yPx: Float, containerSizePx: Int, verticalOffsetPx: Float) { + val cellWidthPx = containerSizePx / columnCount + val cellHeightPx = containerSizePx / rowCount + + if (xPx < 0 || yPx < verticalOffsetPx) { + return + } + + val dotColumn = (xPx / cellWidthPx).toInt() + val dotRow = ((yPx - verticalOffsetPx) / cellHeightPx).toInt() + if (dotColumn > columnCount - 1 || dotRow > rowCount - 1) { + return + } + + val dotPixelX = dotColumn * cellWidthPx + cellWidthPx / 2 + val dotPixelY = dotRow * cellHeightPx + cellHeightPx / 2 + verticalOffsetPx + + val distance = sqrt((xPx - dotPixelX).pow(2) + (yPx - dotPixelY).pow(2)) + val hitRadius = hitFactor * min(cellWidthPx, cellHeightPx) / 2 + if (distance > hitRadius) { + return + } + + val hitDot = dots.value.firstOrNull { dot -> dot.x == dotColumn && dot.y == dotRow } + if (hitDot != null && !_selectedDots.value.contains(hitDot)) { + val skippedOverDots = + currentDot.value?.let { previousDot -> + buildList { + var dot = previousDot + while (dot != hitDot) { + add(dot) + dot = + PatternDotViewModel( + x = + if (hitDot.x > dot.x) dot.x + 1 + else if (hitDot.x < dot.x) dot.x - 1 else dot.x, + y = + if (hitDot.y > dot.y) dot.y + 1 + else if (hitDot.y < dot.y) dot.y - 1 else dot.y, + ) + } + } + } + ?: emptyList() + + _selectedDots.value = + linkedSetOf().apply { + addAll(_selectedDots.value) + addAll(skippedOverDots) + add(hitDot) + } + _currentDot.value = hitDot + } + } + + /** Notifies that the user has ended the drag gesture across the dot grid. */ + fun onDragEnd() { + interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) + + _dots.value = defaultDots() + _currentDot.value = null + _selectedDots.value = linkedSetOf() + } + + private fun defaultDots(): List { + return buildList { + (0 until columnCount).forEach { x -> + (0 until rowCount).forEach { y -> + add( + PatternDotViewModel( + x = x, + y = y, + ) + ) + } + } + } + } + + private val hitFactor: Float by lazy { + val outValue = TypedValue() + applicationContext.resources.getValue( + com.android.internal.R.dimen.lock_pattern_dot_hit_factor, + outValue, + true + ) + max(min(outValue.float, 1f), MIN_DOT_HIT_FACTOR) + } + + companion object { + private const val MIN_DOT_HIT_FACTOR = 0.2f + } +} + +data class PatternDotViewModel( + val x: Int, + val y: Int, +) { + fun toCoordinate(): AuthenticationMethodModel.Pattern.PatternCoordinate { + return AuthenticationMethodModel.Pattern.PatternCoordinate( + x = x, + y = y, + ) + } +} 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 new file mode 100644 index 0000000000000..f9223cb0872e9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -0,0 +1,101 @@ +/* + * 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.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( + private val applicationScope: CoroutineScope, + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + private val entered = MutableStateFlow>(emptyList()) + /** + * The length of the PIN digits that were input so far, two values are supplied the previous and + * the current. + */ + val pinLengths: StateFlow> = + 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 + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** 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()) { + interactor.clearMessage() + } + + entered.value += input + } + + /** Notifies that the user clicked the backspace button. */ + fun onBackspaceButtonClicked() { + if (entered.value.isEmpty()) { + return + } + + entered.value = entered.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) + } + } + } + + /** Notifies that the user clicked the "enter" button. */ + fun onAuthenticateButtonClicked() { + interactor.authenticate(entered.value) + entered.value = emptyList() + } + + companion object { + @VisibleForTesting const val BACKSPACE_LONG_PRESS_DELAY_MS = 80L + } +} 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 new file mode 100644 index 0000000000000..3c8c60368060f --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt @@ -0,0 +1,128 @@ +/* + * 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.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.data.repository.fakeSceneContainerRepository +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +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 BouncerViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + private val underTest = + BouncerViewModel( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + } + }, + containerName = CONTAINER_NAME, + ) + + @Test + fun authMethod_nonNullForSecureMethods_nullForNotSecureMethods() = + testScope.runTest { + val authMethodViewModel: AuthMethodBouncerViewModel? by + collectLastValue(underTest.authMethod) + authMethodsToTest().forEach { authMethod -> + mAuthenticationInteractor.setAuthenticationMethod(authMethod) + + if (authMethod.isSecure) { + assertThat(authMethodViewModel).isNotNull() + } else { + assertThat(authMethodViewModel).isNull() + } + } + } + + @Test + fun authMethod_reusesInstances() = + testScope.runTest { + val seen = mutableMapOf() + val authMethodViewModel: AuthMethodBouncerViewModel? by + collectLastValue(underTest.authMethod) + // First pass, populate our "seen" map: + authMethodsToTest().forEach { authMethod -> + mAuthenticationInteractor.setAuthenticationMethod(authMethod) + authMethodViewModel?.let { seen[authMethod] = it } + } + + // Second pass, assert same instances are reused: + authMethodsToTest().forEach { authMethod -> + mAuthenticationInteractor.setAuthenticationMethod(authMethod) + authMethodViewModel?.let { assertThat(it).isSameInstanceAs(seen[authMethod]) } + } + } + + @Test + fun authMethodsToTest_returnsCompleteSampleOfAllAuthMethodTypes() { + assertThat(authMethodsToTest().map { it::class }.toSet()) + .isEqualTo(AuthenticationMethodModel::class.sealedSubclasses.toSet()) + } + + private fun authMethodsToTest(): List { + return listOf( + AuthenticationMethodModel.None, + AuthenticationMethodModel.Swipe, + AuthenticationMethodModel.PIN(1234), + AuthenticationMethodModel.Password("password"), + AuthenticationMethodModel.Pattern( + listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1)) + ), + ) + } + + companion object { + private const val CONTAINER_NAME = "container1" + } +} 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 new file mode 100644 index 0000000000000..fe808f2f1d85b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -0,0 +1,218 @@ +/* + * 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.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.data.repository.fakeSceneContainerRepository +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class PasswordBouncerViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + private val bouncerInteractor = + BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + private val bouncerViewModel = + BouncerViewModel( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return bouncerInteractor + } + }, + containerName = CONTAINER_NAME, + ) + private val underTest = + PasswordBouncerViewModel( + interactor = bouncerInteractor, + ) + + @Before + fun setUp() { + overrideResource(R.string.keyguard_enter_your_password, ENTER_YOUR_PASSWORD) + overrideResource(R.string.kg_wrong_password, WRONG_PASSWORD) + } + + @Test + fun onShown() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + underTest.onShown() + + assertThat(message).isEqualTo(ENTER_YOUR_PASSWORD) + assertThat(password).isEqualTo("") + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onPasswordInputChanged() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + + underTest.onPasswordInputChanged("password") + + assertThat(message).isEmpty() + assertThat(password).isEqualTo("password") + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onAuthenticateKeyPressed_whenCorrect() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPasswordInputChanged("password") + + underTest.onAuthenticateKeyPressed() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onAuthenticateKeyPressed_whenWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPasswordInputChanged("wrong") + + underTest.onAuthenticateKeyPressed() + + assertThat(password).isEqualTo("") + assertThat(message).isEqualTo(WRONG_PASSWORD) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onAuthenticateKeyPressed_correctAfterWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPasswordInputChanged("wrong") + underTest.onAuthenticateKeyPressed() + assertThat(password).isEqualTo("") + assertThat(message).isEqualTo(WRONG_PASSWORD) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Enter the correct password: + underTest.onPasswordInputChanged("password") + assertThat(message).isEmpty() + + underTest.onAuthenticateKeyPressed() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + private const val ENTER_YOUR_PASSWORD = "Enter your password" + private const val WRONG_PASSWORD = "Wrong password" + } +} 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 new file mode 100644 index 0000000000000..19d43fb89c251 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt @@ -0,0 +1,292 @@ +/* + * 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.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.data.repository.fakeSceneContainerRepository +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class PatternBouncerViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + private val bouncerInteractor = + BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + private val bouncerViewModel = + BouncerViewModel( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return bouncerInteractor + } + }, + containerName = CONTAINER_NAME, + ) + private val underTest = + PatternBouncerViewModel( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactor = bouncerInteractor, + ) + + @Before + fun setUp() { + overrideResource(R.string.keyguard_enter_your_pattern, ENTER_YOUR_PATTERN) + overrideResource(R.string.kg_wrong_pattern, WRONG_PATTERN) + } + + @Test + fun onShown() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + underTest.onShown() + + assertThat(message).isEqualTo(ENTER_YOUR_PATTERN) + assertThat(selectedDots).isEmpty() + assertThat(currentDot).isNull() + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onDragStart() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + + underTest.onDragStart() + + assertThat(message).isEmpty() + assertThat(selectedDots).isEmpty() + assertThat(currentDot).isNull() + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onDragEnd_whenCorrect() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onDragStart() + assertThat(currentDot).isNull() + CORRECT_PATTERN.forEachIndexed { index, coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + assertWithMessage("Wrong selected dots for index $index") + .that(selectedDots) + .isEqualTo( + CORRECT_PATTERN.subList(0, index + 1).map { + PatternDotViewModel( + x = it.x, + y = it.y, + ) + } + ) + assertWithMessage("Wrong current dot for index $index") + .that(currentDot) + .isEqualTo( + PatternDotViewModel( + x = CORRECT_PATTERN.subList(0, index + 1).last().x, + y = CORRECT_PATTERN.subList(0, index + 1).last().y, + ) + ) + } + + underTest.onDragEnd() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onDragEnd_whenWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onDragStart() + CORRECT_PATTERN.subList(0, 3).forEach { coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + } + + underTest.onDragEnd() + + assertThat(selectedDots).isEmpty() + assertThat(currentDot).isNull() + assertThat(message).isEqualTo(WRONG_PATTERN) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onDragEnd_correctAfterWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + mAuthenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onDragStart() + CORRECT_PATTERN.subList(2, 7).forEach { coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + } + underTest.onDragEnd() + assertThat(selectedDots).isEmpty() + assertThat(currentDot).isNull() + assertThat(message).isEqualTo(WRONG_PATTERN) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Enter the correct pattern: + CORRECT_PATTERN.forEach { coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + } + + underTest.onDragEnd() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + private const val ENTER_YOUR_PATTERN = "Enter your pattern" + private const val WRONG_PATTERN = "Wrong pattern" + private val CORRECT_PATTERN = + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 2), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 2), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 2), + ) + } +} 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 new file mode 100644 index 0000000000000..0b6868ffc9939 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -0,0 +1,278 @@ +/* + * 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.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.data.repository.fakeSceneContainerRepository +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class PinBouncerViewModelTest : SysuiTestCase() { + + private val testScope = TestScope() + private val sceneInteractor = + SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + private val mAuthenticationInteractor = + AuthenticationInteractor( + applicationScope = testScope.backgroundScope, + repository = AuthenticationRepositoryImpl(), + ) + private val bouncerInteractor = + BouncerInteractor( + applicationScope = testScope.backgroundScope, + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = mAuthenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_NAME, + ) + private val bouncerViewModel = + BouncerViewModel( + applicationContext = context, + applicationScope = testScope.backgroundScope, + interactorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return bouncerInteractor + } + }, + containerName = CONTAINER_NAME, + ) + private val underTest = + PinBouncerViewModel( + applicationScope = testScope.backgroundScope, + interactor = bouncerInteractor, + ) + + @Before + fun setUp() { + overrideResource(R.string.keyguard_enter_your_pin, ENTER_YOUR_PIN) + overrideResource(R.string.kg_wrong_pin, WRONG_PIN) + } + + @Test + fun onShown() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + underTest.onShown() + + assertThat(message).isEqualTo(ENTER_YOUR_PIN) + assertThat(pinLengths).isEqualTo(0 to 0) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onPinButtonClicked() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + + underTest.onPinButtonClicked(1) + + assertThat(message).isEmpty() + assertThat(pinLengths).isEqualTo(0 to 1) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onBackspaceButtonClicked() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + assertThat(pinLengths).isEqualTo(0 to 1) + + underTest.onBackspaceButtonClicked() + + assertThat(message).isEmpty() + assertThat(pinLengths).isEqualTo(1 to 0) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onBackspaceButtonLongPressed() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.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.onPinButtonClicked(4) + + underTest.onBackspaceButtonLongPressed() + repeat(4) { index -> + assertThat(pinLengths).isEqualTo(4 - index to 3 - index) + advanceTimeBy(PinBouncerViewModel.BACKSPACE_LONG_PRESS_DELAY_MS) + } + + assertThat(message).isEmpty() + assertThat(pinLengths).isEqualTo(1 to 0) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onAuthenticateButtonClicked_whenCorrect() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.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.onPinButtonClicked(4) + + underTest.onAuthenticateButtonClicked() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onAuthenticateButtonClicked_whenWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.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.onPinButtonClicked(4) + underTest.onPinButtonClicked(5) // PIN is now wrong! + + underTest.onAuthenticateButtonClicked() + + assertThat(pinLengths).isEqualTo(0 to 0) + assertThat(message).isEqualTo(WRONG_PIN) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + } + + @Test + fun onAuthenticateButtonClicked_correctAfterWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + mAuthenticationInteractor.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.onPinButtonClicked(4) + underTest.onPinButtonClicked(5) // PIN is now wrong! + underTest.onAuthenticateButtonClicked() + assertThat(message).isEqualTo(WRONG_PIN) + assertThat(pinLengths).isEqualTo(0 to 0) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Enter the correct PIN: + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.onPinButtonClicked(4) + assertThat(message).isEmpty() + + underTest.onAuthenticateButtonClicked() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + private const val ENTER_YOUR_PIN = "Enter your pin" + private const val WRONG_PIN = "Wrong pin" + } +}