[flexiglass] Multiple input method bouncer scene.

Expanded the placeholder bouncer scene to correctly show different UIs
based on the authentication method and call the correct auth APIs to
check user input, unlock the device, etc.

Bug: 280877228
Test: unit tests included. Manually tested with the entire relation
chain in the Compose Gallery testdbed app.

Change-Id: I1dff6dc1d849fd0b3d5d5dff300485e3ef770f08
This commit is contained in:
Alejandro Nijamkin
2023-05-05 16:23:25 -07:00
parent 6de6b03896
commit eebf105403
13 changed files with 2023 additions and 42 deletions

View File

@@ -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,
)
}
}
}

View File

@@ -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))
}
}

View File

@@ -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<PatternDotViewModel> 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<PatternDotViewModel> 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)
}

View File

@@ -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<Int, Int> 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

View File

@@ -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

View File

@@ -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<AuthMethodBouncerViewModel?> =
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<String> =
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
}
}
}

View File

@@ -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<String> = _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 = ""
}
}

View File

@@ -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<LinkedHashSet<PatternDotViewModel>>(linkedSetOf())
/** The dots that were selected by the user, in the order of selection. */
val selectedDots: StateFlow<List<PatternDotViewModel>> =
_selectedDots
.map { it.toList() }
.stateIn(
scope = applicationScope,
started = SharingStarted.WhileSubscribed(),
initialValue = emptyList(),
)
private val _currentDot = MutableStateFlow<PatternDotViewModel?>(null)
/** The most-recently selected dot that the user selected. */
val currentDot: StateFlow<PatternDotViewModel?> = _currentDot.asStateFlow()
private val _dots = MutableStateFlow(defaultDots())
/** All dots on the grid. */
val dots: StateFlow<List<PatternDotViewModel>> = _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<PatternDotViewModel>().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<PatternDotViewModel> {
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,
)
}
}

View File

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

View File

@@ -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<AuthenticationMethodModel, AuthMethodBouncerViewModel>()
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<AuthenticationMethodModel> {
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"
}
}

View File

@@ -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"
}
}

View File

@@ -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),
)
}
}

View File

@@ -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"
}
}