[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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user