diff --git a/packages/SystemUI/compose/core/src/com/android/compose/grid/Grids.kt b/packages/SystemUI/compose/core/src/com/android/compose/grid/Grids.kt new file mode 100644 index 0000000000000..5224c51bb7c34 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/compose/grid/Grids.kt @@ -0,0 +1,190 @@ +/* + * 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.compose.grid + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.roundToInt + +/** + * Renders a grid with [columns] columns. + * + * Child composables will be arranged row by row. + * + * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell + * inside a column is spaced from the cells above and below it with [verticalSpacing]. + */ +@Composable +fun VerticalGrid( + columns: Int, + modifier: Modifier = Modifier, + verticalSpacing: Dp = 0.dp, + horizontalSpacing: Dp = 0.dp, + content: @Composable () -> Unit, +) { + Grid( + primarySpaces = columns, + isVertical = true, + modifier = modifier, + verticalSpacing = verticalSpacing, + horizontalSpacing = horizontalSpacing, + content = content, + ) +} + +/** + * Renders a grid with [rows] rows. + * + * Child composables will be arranged column by column. + * + * Each column is spaced from the columns to its left and right by [horizontalSpacing]. Each cell + * inside a column is spaced from the cells above and below it with [verticalSpacing]. + */ +@Composable +fun HorizontalGrid( + rows: Int, + modifier: Modifier = Modifier, + verticalSpacing: Dp = 0.dp, + horizontalSpacing: Dp = 0.dp, + content: @Composable () -> Unit, +) { + Grid( + primarySpaces = rows, + isVertical = false, + modifier = modifier, + verticalSpacing = verticalSpacing, + horizontalSpacing = horizontalSpacing, + content = content, + ) +} + +@Composable +private fun Grid( + primarySpaces: Int, + isVertical: Boolean, + modifier: Modifier = Modifier, + verticalSpacing: Dp, + horizontalSpacing: Dp, + content: @Composable () -> Unit, +) { + check(primarySpaces > 0) { + "Must provide a positive number of ${if (isVertical) "columns" else "rows"}" + } + + val sizeCache = remember { + object { + var rowHeights = intArrayOf() + var columnWidths = intArrayOf() + } + } + + Layout( + modifier = modifier, + content = content, + ) { measurables, constraints -> + val cells = measurables.size + val columns: Int + val rows: Int + if (isVertical) { + columns = primarySpaces + rows = ceil(cells.toFloat() / primarySpaces).toInt() + } else { + columns = ceil(cells.toFloat() / primarySpaces).toInt() + rows = primarySpaces + } + + if (sizeCache.rowHeights.size != rows) { + sizeCache.rowHeights = IntArray(rows) { 0 } + } + if (sizeCache.columnWidths.size != columns) { + sizeCache.columnWidths = IntArray(columns) { 0 } + } + + val totalHorizontalSpacingBetweenChildren = + ((columns - 1) * horizontalSpacing.toPx()).roundToInt() + val totalVerticalSpacingBetweenChildren = ((rows - 1) * verticalSpacing.toPx()).roundToInt() + val childConstraints = + Constraints().apply { + if (constraints.maxWidth != Constraints.Infinity) { + constrainWidth( + (constraints.maxWidth - totalHorizontalSpacingBetweenChildren) / columns + ) + } + if (constraints.maxHeight != Constraints.Infinity) { + constrainWidth( + (constraints.maxHeight - totalVerticalSpacingBetweenChildren) / rows + ) + } + } + + val placeables = buildList { + for (cellIndex in measurables.indices) { + val column: Int + val row: Int + if (isVertical) { + column = cellIndex % columns + row = cellIndex / columns + } else { + column = cellIndex / rows + row = cellIndex % rows + } + + val placeable = measurables[cellIndex].measure(childConstraints) + sizeCache.rowHeights[row] = max(sizeCache.rowHeights[row], placeable.height) + sizeCache.columnWidths[column] = + max(sizeCache.columnWidths[column], placeable.width) + add(placeable) + } + } + + var totalWidth = totalHorizontalSpacingBetweenChildren + for (column in sizeCache.columnWidths.indices) { + totalWidth += sizeCache.columnWidths[column] + } + + var totalHeight = totalVerticalSpacingBetweenChildren + for (row in sizeCache.rowHeights.indices) { + totalHeight += sizeCache.rowHeights[row] + } + + layout(totalWidth, totalHeight) { + var y = 0 + repeat(rows) { row -> + var x = 0 + var maxChildHeight = 0 + repeat(columns) { column -> + val cellIndex = row * columns + column + if (cellIndex < cells) { + val placeable = placeables[cellIndex] + placeable.placeRelative(x, y) + x += placeable.width + horizontalSpacing.roundToPx() + maxChildHeight = max(maxChildHeight, placeable.height) + } + } + y += maxChildHeight + verticalSpacing.roundToPx() + } + } + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt index 853300cf95081..c6c30199a2273 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/BouncerScene.kt @@ -16,18 +16,30 @@ package com.android.systemui.bouncer.ui.composable +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.android.systemui.bouncer.ui.viewmodel.AuthMethodBouncerViewModel import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel import com.android.systemui.dagger.SysUISingleton import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel @@ -55,26 +67,69 @@ constructor( ) .asStateFlow() - @Composable - override fun Content( - modifier: Modifier, - ) { - // TODO(b/280877228): implement the real UI. + @Composable override fun Content(modifier: Modifier) = BouncerScene(viewModel, modifier) +} - Box(modifier = modifier) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.align(Alignment.Center) - ) { - Text("Bouncer", style = MaterialTheme.typography.headlineLarge) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button(onClick = { viewModel.onAuthenticateButtonClicked() }) { - Text("Authenticate") - } - } +@Composable +private fun BouncerScene( + viewModel: BouncerViewModel, + modifier: Modifier = Modifier, +) { + val message: String by viewModel.message.collectAsState() + val authMethodViewModel: AuthMethodBouncerViewModel? by viewModel.authMethod.collectAsState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(60.dp), + modifier = + modifier.background(MaterialTheme.colorScheme.surface).fillMaxSize().padding(32.dp) + ) { + Crossfade( + targetState = message, + label = "Bouncer message", + ) { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge, + ) + } + + Box(Modifier.weight(1f)) { + when (val nonNullViewModel = authMethodViewModel) { + is PinBouncerViewModel -> + PinBouncer( + viewModel = nonNullViewModel, + modifier = Modifier.align(Alignment.Center), + ) + is PasswordBouncerViewModel -> + PasswordBouncer( + viewModel = nonNullViewModel, + modifier = Modifier.align(Alignment.Center), + ) + is PatternBouncerViewModel -> + PatternBouncer( + viewModel = nonNullViewModel, + modifier = + Modifier.aspectRatio(1f, matchHeightConstraintsFirst = false) + .align(Alignment.BottomCenter), + ) + else -> Unit } } + + Button( + onClick = viewModel::onEmergencyServicesButtonClicked, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Text( + text = stringResource(com.android.internal.R.string.lockscreen_emergency_call), + style = MaterialTheme.typography.bodyMedium, + ) + } } } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt new file mode 100644 index 0000000000000..4e85621e9e233 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PasswordBouncer.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.composable + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.android.systemui.bouncer.ui.viewmodel.PasswordBouncerViewModel + +/** UI for the input part of a password-requiring version of the bouncer. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun PasswordBouncer( + viewModel: PasswordBouncerViewModel, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val password: String by viewModel.password.collectAsState() + + LaunchedEffect(Unit) { + // When the UI comes up, request focus on the TextField to bring up the software keyboard. + focusRequester.requestFocus() + // Also, report that the UI is shown to let the view-model runs some logic. + viewModel.onShown() + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + val color = MaterialTheme.colorScheme.onSurfaceVariant + val lineWidthPx = with(LocalDensity.current) { 2.dp.toPx() } + + TextField( + value = password, + onValueChange = viewModel::onPasswordInputChanged, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + textStyle = LocalTextStyle.current.copy(textAlign = TextAlign.Center), + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { viewModel.onAuthenticateKeyPressed() }, + ), + modifier = + Modifier.focusRequester(focusRequester).drawBehind { + drawLine( + color = color, + start = Offset(x = 0f, y = size.height - lineWidthPx), + end = Offset(size.width, y = size.height - lineWidthPx), + strokeWidth = lineWidthPx, + ) + }, + ) + + Spacer(Modifier.height(100.dp)) + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt new file mode 100644 index 0000000000000..383c748f5dd8f --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PatternBouncer.kt @@ -0,0 +1,281 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.composable + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.android.systemui.bouncer.ui.viewmodel.PatternBouncerViewModel +import com.android.systemui.bouncer.ui.viewmodel.PatternDotViewModel +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt +import kotlinx.coroutines.launch + +/** + * UI for the input part of a pattern-requiring version of the bouncer. + * + * The user can press, hold, and drag their pointer to select dots along a grid of dots. + */ +@Composable +internal fun PatternBouncer( + viewModel: PatternBouncerViewModel, + modifier: Modifier = Modifier, +) { + // Report that the UI is shown to let the view-model run some logic. + LaunchedEffect(Unit) { viewModel.onShown() } + + val colCount = viewModel.columnCount + val rowCount = viewModel.rowCount + + val dotColor = MaterialTheme.colorScheme.secondary + val dotRadius = with(LocalDensity.current) { 8.dp.toPx() } + val lineColor = MaterialTheme.colorScheme.primary + val lineStrokeWidth = dotRadius * 2 + with(LocalDensity.current) { 4.dp.toPx() } + + var containerSize: IntSize by remember { mutableStateOf(IntSize(0, 0)) } + val horizontalSpacing = containerSize.width / colCount + val verticalSpacing = containerSize.height / rowCount + val spacing = min(horizontalSpacing, verticalSpacing).toFloat() + val verticalOffset = containerSize.height - spacing * rowCount + + // All dots that should be rendered on the grid. + val dots: List by viewModel.dots.collectAsState() + // The most recently selected dot, if the user is currently dragging. + val currentDot: PatternDotViewModel? by viewModel.currentDot.collectAsState() + // The dots selected so far, if the user is currently dragging. + val selectedDots: List by viewModel.selectedDots.collectAsState() + + // Map of animatables for the scale of each dot, keyed by dot. + val scales = remember(dots) { dots.associateWith { Animatable(1f) } } + // Map of animatables for the lines that connect between selected dots, keyed by the destination + // dot of the line. + val lines = remember(dots) { dots.associateWith { Animatable(1f) } } + + val scope = rememberCoroutineScope() + + // When the current dot is changed, we need to update our animations. + LaunchedEffect(currentDot) { + // Make sure that the current dot is scaled up while the other dots are scaled back down. + scales.entries.forEach { (dot, animatable) -> + val isSelected = dot == currentDot + launch { + animatable.animateTo(if (isSelected) 2f else 1f) + if (isSelected) { + animatable.animateTo(1f) + } + } + } + + // Make sure that all dot-connecting lines are decaying, if they're not already animating. + selectedDots.forEach { + lines[it]?.let { line -> + if (!line.isRunning) { + scope.launch { + line.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500), + ) + } + } + } + } + } + + // This is the position of the input pointer. + var inputPosition: Offset? by remember { mutableStateOf(null) } + + Canvas( + modifier + // Need to clip to bounds to make sure that the lines don't follow the input pointer + // when it leaves the bounds of the dot grid. + .clipToBounds() + .onSizeChanged { containerSize = it } + .pointerInput(Unit) { + detectDragGestures( + onDragStart = { start -> + inputPosition = start + viewModel.onDragStart() + }, + onDragEnd = { + inputPosition = null + lines.values.forEach { animatable -> + scope.launch { animatable.animateTo(1f) } + } + viewModel.onDragEnd() + }, + ) { change, _ -> + inputPosition = change.position + viewModel.onDrag( + xPx = change.position.x, + yPx = change.position.y, + containerSizePx = containerSize.width, + verticalOffsetPx = verticalOffset, + ) + } + } + ) { + // Draw lines between dots. + selectedDots.forEachIndexed { index, dot -> + if (index > 0) { + val previousDot = selectedDots[index - 1] + drawLine( + from = previousDot, + to = dot, + alpha = { distance -> lineAlpha(spacing, distance) }, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) + } + } + + // Draw the line between the most recently-selected dot and the input pointer position. + inputPosition?.let { lineEnd -> + currentDot?.let { dot -> + drawLine( + from = dot, + to = lineEnd, + alpha = { distance -> lineAlpha(spacing, distance) }, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) + } + } + + // Draw each dot on the grid. + dots.forEach { dot -> + drawDot( + dot = dot, + scaleFactor = { scales[dot]?.value ?: 1f }, + spacing = spacing, + verticalOffset = verticalOffset, + dotColor = dotColor, + dotRadius = dotRadius, + ) + } + } +} + +/** Draws the given [dot]. */ +private fun DrawScope.drawDot( + dot: PatternDotViewModel, + scaleFactor: () -> Float, + spacing: Float, + verticalOffset: Float, + dotColor: Color, + dotRadius: Float, +) { + drawCircle( + color = dotColor, + radius = dotRadius * scaleFactor.invoke(), + center = pixelOffset(dot, spacing, verticalOffset), + ) +} + +/** Draws a line from the [from] origin dot to the [to] destination dot. */ +private fun DrawScope.drawLine( + from: PatternDotViewModel, + to: PatternDotViewModel, + alpha: (distance: Float) -> Float, + spacing: Float, + verticalOffset: Float, + lineColor: Color, + lineStrokeWidth: Float, +) { + drawLine( + from = from, + to = pixelOffset(to, spacing, verticalOffset), + alpha = alpha, + spacing = spacing, + verticalOffset = verticalOffset, + lineColor = lineColor, + lineStrokeWidth = lineStrokeWidth, + ) +} + +/** Draws a line from the [from] origin dot to the [to] destination. */ +private fun DrawScope.drawLine( + from: PatternDotViewModel, + to: Offset, + alpha: (distance: Float) -> Float, + spacing: Float, + verticalOffset: Float, + lineColor: Color, + lineStrokeWidth: Float, +) { + val fromAsOffset = pixelOffset(from, spacing, verticalOffset) + val distance = sqrt((to.y - fromAsOffset.y).pow(2) + (to.x - fromAsOffset.x).pow(2)) + + drawLine( + color = lineColor, + start = fromAsOffset, + end = to, + strokeWidth = lineStrokeWidth, + cap = StrokeCap.Round, + alpha = alpha.invoke(distance), + ) +} + +/** Returns an [Offset] representation of the given [dot] in pixel coordinates. */ +private fun pixelOffset( + dot: PatternDotViewModel, + spacing: Float, + verticalOffset: Float, +): Offset { + return Offset( + x = dot.x * spacing + spacing / 2, + y = dot.y * spacing + spacing / 2 + verticalOffset, + ) +} + +/** + * Returns the alpha for a line between dots where dots are [spacing] apart from each other on the + * dot grid and the line ends [distance] away from the origin dot. + * + * The reason [distance] can be different from [spacing] is that all lines originate in dots but one + * line might end where the user input pointer is, which isn't always a dot position. + */ +private fun lineAlpha(spacing: Float, distance: Float): Float { + // Custom curve for the alpha of a line as a function of its distance from its source dot. The + // farther the user input pointer goes from the line, the more opaque the line gets. + return ((distance / spacing - 0.3f) * 4f).coerceIn(0f, 1f) +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt new file mode 100644 index 0000000000000..9c210c225ab39 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/bouncer/ui/composable/PinBouncer.kt @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalAnimationApi::class) + +package com.android.systemui.bouncer.ui.composable + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.compose.grid.VerticalGrid +import com.android.systemui.R +import com.android.systemui.bouncer.ui.viewmodel.PinBouncerViewModel +import com.android.systemui.common.shared.model.ContentDescription +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.common.ui.compose.Icon +import kotlin.math.max + +@Composable +internal fun PinBouncer( + viewModel: PinBouncerViewModel, + modifier: Modifier = Modifier, +) { + // Report that the UI is shown to let the view-model run some logic. + LaunchedEffect(Unit) { viewModel.onShown() } + + // The length of the PIN input received so far, so we know how many dots to render. + val pinLength: Pair by viewModel.pinLengths.collectAsState() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.heightIn(min = 16.dp).animateContentSize(), + ) { + // TODO(b/281871687): add support for dot shapes. + val (previousPinLength, currentPinLength) = pinLength + val dotCount = max(previousPinLength, currentPinLength) + 1 + repeat(dotCount) { index -> + AnimatedVisibility( + visible = index < currentPinLength, + enter = fadeIn() + scaleIn() + slideInHorizontally(), + exit = fadeOut() + scaleOut() + slideOutHorizontally(), + ) { + Box( + modifier = + Modifier.size(16.dp) + .background( + MaterialTheme.colorScheme.onSurfaceVariant, + CircleShape, + ) + ) + } + } + } + + Spacer(Modifier.height(100.dp)) + + VerticalGrid( + columns = 3, + verticalSpacing = 12.dp, + horizontalSpacing = 20.dp, + ) { + repeat(9) { index -> + val digit = index + 1 + PinButton( + onClicked = { viewModel.onPinButtonClicked(digit) }, + ) { contentColor -> + PinDigit(digit, contentColor) + } + } + + PinButton( + onClicked = { viewModel.onBackspaceButtonClicked() }, + onLongPressed = { viewModel.onBackspaceButtonLongPressed() }, + isHighlighted = true, + ) { contentColor -> + PinIcon( + Icon.Resource( + res = R.drawable.ic_backspace_24dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_delete), + ), + contentColor, + ) + } + + PinButton( + onClicked = { viewModel.onPinButtonClicked(0) }, + ) { contentColor -> + PinDigit(0, contentColor) + } + + PinButton( + onClicked = { viewModel.onAuthenticateButtonClicked() }, + isHighlighted = true, + ) { contentColor -> + PinIcon( + Icon.Resource( + res = R.drawable.ic_keyboard_tab_36dp, + contentDescription = + ContentDescription.Resource(R.string.keyboardview_keycode_enter), + ), + contentColor, + ) + } + } + } +} + +@Composable +private fun PinDigit( + digit: Int, + contentColor: Color, +) { + // TODO(b/281878426): once "color: () -> Color" (added to BasicText in aosp/2568972) makes it + // into Text, use that here, to animate more efficiently. + Text( + text = digit.toString(), + style = MaterialTheme.typography.headlineLarge, + color = contentColor, + ) +} + +@Composable +private fun PinIcon( + icon: Icon, + contentColor: Color, +) { + Icon( + icon = icon, + tint = contentColor, + ) +} + +@Composable +private fun PinButton( + onClicked: () -> Unit, + modifier: Modifier = Modifier, + onLongPressed: (() -> Unit)? = null, + isHighlighted: Boolean = false, + content: @Composable (contentColor: Color) -> Unit, +) { + var isPressed: Boolean by remember { mutableStateOf(false) } + val cornerRadius: Dp by + animateDpAsState( + if (isPressed) 24.dp else PinButtonSize / 2, + label = "PinButton round corners", + ) + val containerColor: Color by + animateColorAsState( + when { + isPressed -> MaterialTheme.colorScheme.primaryContainer + isHighlighted -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + }, + label = "Pin button container color", + ) + val contentColor: Color by + animateColorAsState( + when { + isPressed -> MaterialTheme.colorScheme.onPrimaryContainer + isHighlighted -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface + }, + label = "Pin button container color", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = + modifier + .size(PinButtonSize) + .drawBehind { + drawRoundRect( + color = containerColor, + cornerRadius = CornerRadius(cornerRadius.toPx()), + ) + } + .pointerInput(Unit) { + detectTapGestures( + onPress = { + isPressed = true + tryAwaitRelease() + isPressed = false + }, + onTap = { onClicked() }, + onLongPress = onLongPressed?.let { { onLongPressed() } }, + ) + }, + ) { + content(contentColor) + } +} + +private val PinButtonSize = 84.dp diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt new file mode 100644 index 0000000000000..ebefb78f0477d --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/AuthMethodBouncerViewModel.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.viewmodel + +sealed interface AuthMethodBouncerViewModel diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt index 8a183ae6ca7ce..eaa8ed5b358ef 100644 --- a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModel.kt @@ -16,6 +16,7 @@ package com.android.systemui.bouncer.ui.viewmodel +import android.content.Context import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.bouncer.domain.interactor.BouncerInteractor import com.android.systemui.dagger.qualifiers.Application @@ -30,12 +31,44 @@ import kotlinx.coroutines.flow.stateIn class BouncerViewModel @AssistedInject constructor( + @Application private val applicationContext: Context, @Application private val applicationScope: CoroutineScope, interactorFactory: BouncerInteractor.Factory, containerName: String, ) { private val interactor: BouncerInteractor = interactorFactory.create(containerName) + private val pin: PinBouncerViewModel by lazy { + PinBouncerViewModel( + applicationScope = applicationScope, + interactor = interactor, + ) + } + + private val password: PasswordBouncerViewModel by lazy { + PasswordBouncerViewModel( + interactor = interactor, + ) + } + + private val pattern: PatternBouncerViewModel by lazy { + PatternBouncerViewModel( + applicationContext = applicationContext, + applicationScope = applicationScope, + interactor = interactor, + ) + } + + /** View-model for the current UI, based on the current authentication method. */ + val authMethod: StateFlow = + interactor.authenticationMethod + .map { authMethod -> toViewModel(authMethod) } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = toViewModel(interactor.authenticationMethod.value), + ) + /** The user-facing message to show in the bouncer. */ val message: StateFlow = interactor.message @@ -46,30 +79,19 @@ constructor( initialValue = interactor.message.value ?: "", ) - /** Notifies that the authenticate button was clicked. */ - fun onAuthenticateButtonClicked() { - // TODO(b/280877228): remove this and send the real input. - interactor.authenticate( - when (interactor.authenticationMethod.value) { - is AuthenticationMethodModel.PIN -> listOf(1, 2, 3, 4) - is AuthenticationMethodModel.Password -> "password".toList() - is AuthenticationMethodModel.Pattern -> - listOf( - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 0), - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(2, 2), - AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 0), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 1), - AuthenticationMethodModel.Pattern.PatternCoordinate(0, 2), - ) - else -> emptyList() - } - ) - } - /** Notifies that the emergency services button was clicked. */ fun onEmergencyServicesButtonClicked() { - // TODO(b/280877228): implement this. + // TODO(b/280877228): implement this + } + + private fun toViewModel( + authMethod: AuthenticationMethodModel, + ): AuthMethodBouncerViewModel? { + return when (authMethod) { + is AuthenticationMethodModel.PIN -> pin + is AuthenticationMethodModel.Password -> password + is AuthenticationMethodModel.Pattern -> pattern + else -> null + } } } diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt new file mode 100644 index 0000000000000..730d4e8ba0503 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModel.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.viewmodel + +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Holds UI state and handles user input for the password bouncer UI. */ +class PasswordBouncerViewModel( + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + private val _password = MutableStateFlow("") + /** The password entered so far. */ + val password: StateFlow = _password.asStateFlow() + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** Notifies that the user has changed the password input. */ + fun onPasswordInputChanged(password: String) { + if (this.password.value.isEmpty() && password.isNotEmpty()) { + interactor.clearMessage() + } + + _password.value = password + } + + /** Notifies that the user has pressed the key for attempting to authenticate the password. */ + fun onAuthenticateKeyPressed() { + interactor.authenticate(password.value.toCharArray().toList()) + _password.value = "" + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt new file mode 100644 index 0000000000000..eb1b45771ad45 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModel.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.viewmodel + +import android.content.Context +import android.util.TypedValue +import com.android.systemui.authentication.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.sqrt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +/** Holds UI state and handles user input for the pattern bouncer UI. */ +class PatternBouncerViewModel( + private val applicationContext: Context, + applicationScope: CoroutineScope, + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + /** The number of columns in the dot grid. */ + val columnCount = 3 + /** The number of rows in the dot grid. */ + val rowCount = 3 + + private val _selectedDots = MutableStateFlow>(linkedSetOf()) + /** The dots that were selected by the user, in the order of selection. */ + val selectedDots: StateFlow> = + _selectedDots + .map { it.toList() } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = emptyList(), + ) + + private val _currentDot = MutableStateFlow(null) + /** The most-recently selected dot that the user selected. */ + val currentDot: StateFlow = _currentDot.asStateFlow() + + private val _dots = MutableStateFlow(defaultDots()) + /** All dots on the grid. */ + val dots: StateFlow> = _dots.asStateFlow() + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** Notifies that the user has started a drag gesture across the dot grid. */ + fun onDragStart() { + interactor.clearMessage() + } + + /** + * Notifies that the user is dragging across the dot grid. + * + * @param xPx The horizontal coordinate of the position of the user's pointer, in pixels. + * @param yPx The vertical coordinate of the position of the user's pointer, in pixels. + * @param containerSizePx The size of the container of the dot grid, in pixels. It's assumed + * that the dot grid is perfectly square such that width and height are equal. + * @param verticalOffsetPx How far down from `0` does the dot grid start on the display. + */ + fun onDrag(xPx: Float, yPx: Float, containerSizePx: Int, verticalOffsetPx: Float) { + val cellWidthPx = containerSizePx / columnCount + val cellHeightPx = containerSizePx / rowCount + + if (xPx < 0 || yPx < verticalOffsetPx) { + return + } + + val dotColumn = (xPx / cellWidthPx).toInt() + val dotRow = ((yPx - verticalOffsetPx) / cellHeightPx).toInt() + if (dotColumn > columnCount - 1 || dotRow > rowCount - 1) { + return + } + + val dotPixelX = dotColumn * cellWidthPx + cellWidthPx / 2 + val dotPixelY = dotRow * cellHeightPx + cellHeightPx / 2 + verticalOffsetPx + + val distance = sqrt((xPx - dotPixelX).pow(2) + (yPx - dotPixelY).pow(2)) + val hitRadius = hitFactor * min(cellWidthPx, cellHeightPx) / 2 + if (distance > hitRadius) { + return + } + + val hitDot = dots.value.firstOrNull { dot -> dot.x == dotColumn && dot.y == dotRow } + if (hitDot != null && !_selectedDots.value.contains(hitDot)) { + val skippedOverDots = + currentDot.value?.let { previousDot -> + buildList { + var dot = previousDot + while (dot != hitDot) { + add(dot) + dot = + PatternDotViewModel( + x = + if (hitDot.x > dot.x) dot.x + 1 + else if (hitDot.x < dot.x) dot.x - 1 else dot.x, + y = + if (hitDot.y > dot.y) dot.y + 1 + else if (hitDot.y < dot.y) dot.y - 1 else dot.y, + ) + } + } + } + ?: emptyList() + + _selectedDots.value = + linkedSetOf().apply { + addAll(_selectedDots.value) + addAll(skippedOverDots) + add(hitDot) + } + _currentDot.value = hitDot + } + } + + /** Notifies that the user has ended the drag gesture across the dot grid. */ + fun onDragEnd() { + interactor.authenticate(_selectedDots.value.map { it.toCoordinate() }) + + _dots.value = defaultDots() + _currentDot.value = null + _selectedDots.value = linkedSetOf() + } + + private fun defaultDots(): List { + return buildList { + (0 until columnCount).forEach { x -> + (0 until rowCount).forEach { y -> + add( + PatternDotViewModel( + x = x, + y = y, + ) + ) + } + } + } + } + + private val hitFactor: Float by lazy { + val outValue = TypedValue() + applicationContext.resources.getValue( + com.android.internal.R.dimen.lock_pattern_dot_hit_factor, + outValue, + true + ) + max(min(outValue.float, 1f), MIN_DOT_HIT_FACTOR) + } + + companion object { + private const val MIN_DOT_HIT_FACTOR = 0.2f + } +} + +data class PatternDotViewModel( + val x: Int, + val y: Int, +) { + fun toCoordinate(): AuthenticationMethodModel.Pattern.PatternCoordinate { + return AuthenticationMethodModel.Pattern.PatternCoordinate( + x = x, + y = y, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt new file mode 100644 index 0000000000000..f9223cb0872e9 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModel.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.systemui.bouncer.ui.viewmodel + +import androidx.annotation.VisibleForTesting +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.util.kotlin.pairwise +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Holds UI state and handles user input for the PIN code bouncer UI. */ +class PinBouncerViewModel( + private val applicationScope: CoroutineScope, + private val interactor: BouncerInteractor, +) : AuthMethodBouncerViewModel { + + private val entered = MutableStateFlow>(emptyList()) + /** + * The length of the PIN digits that were input so far, two values are supplied the previous and + * the current. + */ + val pinLengths: StateFlow> = + entered + .pairwise() + .map { it.previousValue.size to it.newValue.size } + .stateIn( + scope = applicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = 0 to 0, + ) + private var resetPinJob: Job? = null + + /** Notifies that the UI has been shown to the user. */ + fun onShown() { + interactor.resetMessage() + } + + /** Notifies that the user clicked on a PIN button with the given digit value. */ + fun onPinButtonClicked(input: Int) { + resetPinJob?.cancel() + resetPinJob = null + + if (entered.value.isEmpty()) { + interactor.clearMessage() + } + + entered.value += input + } + + /** Notifies that the user clicked the backspace button. */ + fun onBackspaceButtonClicked() { + if (entered.value.isEmpty()) { + return + } + + entered.value = entered.value.toMutableList().apply { removeLast() } + } + + /** Notifies that the user long-pressed the backspace button. */ + fun onBackspaceButtonLongPressed() { + resetPinJob?.cancel() + resetPinJob = + applicationScope.launch { + while (entered.value.isNotEmpty()) { + onBackspaceButtonClicked() + delay(BACKSPACE_LONG_PRESS_DELAY_MS) + } + } + } + + /** Notifies that the user clicked the "enter" button. */ + fun onAuthenticateButtonClicked() { + interactor.authenticate(entered.value) + entered.value = emptyList() + } + + companion object { + @VisibleForTesting const val BACKSPACE_LONG_PRESS_DELAY_MS = 80L + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt index 2e62bebfe3f1a..44c99053eb475 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/authentication/domain/interactor/AuthenticationInteractorTest.kt @@ -19,9 +19,9 @@ package com.android.systemui.authentication.domain.interactor import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.authentication.data.repository.AuthenticationRepository -import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl import com.android.systemui.authentication.shared.model.AuthenticationMethodModel import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils import com.google.common.truth.Truth.assertThat import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -37,10 +37,10 @@ import org.junit.runners.JUnit4 class AuthenticationInteractorTest : SysuiTestCase() { private val testScope = TestScope() - private val repository: AuthenticationRepository = AuthenticationRepositoryImpl() + private val utils = SceneTestUtils(this, testScope) + private val repository: AuthenticationRepository = utils.authenticationRepository() private val underTest = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, + utils.authenticationInteractor( repository = repository, ) diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt index 7dd376ede3617..730f89dd76ba8 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/domain/interactor/BouncerInteractorTest.kt @@ -19,13 +19,9 @@ package com.android.systemui.bouncer.domain.interactor 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.coroutines.collectLastValue -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -44,23 +40,16 @@ import org.junit.runners.JUnit4 class BouncerInteractorTest : SysuiTestCase() { private val testScope = TestScope() + private val utils = SceneTestUtils(this, testScope) private val authenticationInteractor = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, - repository = AuthenticationRepositoryImpl(), - ) - private val sceneInteractor = - SceneInteractor( - repository = fakeSceneContainerRepository(), + utils.authenticationInteractor( + repository = utils.authenticationRepository(), ) + private val sceneInteractor = utils.sceneInteractor() private val underTest = - BouncerInteractor( - applicationScope = testScope.backgroundScope, - applicationContext = context, - repository = BouncerRepository(), + utils.bouncerInteractor( authenticationInteractor = authenticationInteractor, sceneInteractor = sceneInteractor, - containerName = "container1", ) @Before diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt new file mode 100644 index 0000000000000..954e67d77181b --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/BouncerViewModelTest.kt @@ -0,0 +1,103 @@ +/* + * 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.shared.model.AuthenticationMethodModel +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils +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 utils = SceneTestUtils(this, testScope) + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), + ) + private val underTest = + utils.bouncerViewModel( + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = utils.sceneInteractor(), + ) + ) + + @Test + fun authMethod_nonNullForSecureMethods_nullForNotSecureMethods() = + testScope.runTest { + val authMethodViewModel: AuthMethodBouncerViewModel? by + collectLastValue(underTest.authMethod) + authMethodsToTest().forEach { authMethod -> + authenticationInteractor.setAuthenticationMethod(authMethod) + + if (authMethod.isSecure) { + assertThat(authMethodViewModel).isNotNull() + } else { + assertThat(authMethodViewModel).isNull() + } + } + } + + @Test + fun authMethod_reusesInstances() = + testScope.runTest { + val seen = mutableMapOf() + val authMethodViewModel: AuthMethodBouncerViewModel? by + collectLastValue(underTest.authMethod) + // First pass, populate our "seen" map: + authMethodsToTest().forEach { authMethod -> + authenticationInteractor.setAuthenticationMethod(authMethod) + authMethodViewModel?.let { seen[authMethod] = it } + } + + // Second pass, assert same instances are reused: + authMethodsToTest().forEach { authMethod -> + authenticationInteractor.setAuthenticationMethod(authMethod) + authMethodViewModel?.let { assertThat(it).isSameInstanceAs(seen[authMethod]) } + } + } + + @Test + fun authMethodsToTest_returnsCompleteSampleOfAllAuthMethodTypes() { + assertThat(authMethodsToTest().map { it::class }.toSet()) + .isEqualTo(AuthenticationMethodModel::class.sealedSubclasses.toSet()) + } + + private fun authMethodsToTest(): List { + return listOf( + AuthenticationMethodModel.None, + AuthenticationMethodModel.Swipe, + AuthenticationMethodModel.PIN(1234), + AuthenticationMethodModel.Password("password"), + AuthenticationMethodModel.Pattern( + listOf(AuthenticationMethodModel.Pattern.PatternCoordinate(1, 1)) + ), + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt new file mode 100644 index 0000000000000..e48b6386c7395 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PasswordBouncerViewModelTest.kt @@ -0,0 +1,198 @@ +/* + * 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.shared.model.AuthenticationMethodModel +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.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 utils = SceneTestUtils(this, testScope) + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), + ) + private val sceneInteractor = utils.sceneInteractor() + private val bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ) + private val bouncerViewModel = + utils.bouncerViewModel( + bouncerInteractor = bouncerInteractor, + ) + 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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + authenticationInteractor.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + authenticationInteractor.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + authenticationInteractor.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + authenticationInteractor.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val password by collectLastValue(underTest.password) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Password("password") + ) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPasswordInputChanged("wrong") + underTest.onAuthenticateKeyPressed() + assertThat(password).isEqualTo("") + assertThat(message).isEqualTo(WRONG_PASSWORD) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Enter the correct password: + underTest.onPasswordInputChanged("password") + assertThat(message).isEmpty() + + underTest.onAuthenticateKeyPressed() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + private const val ENTER_YOUR_PASSWORD = "Enter your password" + private const val WRONG_PASSWORD = "Wrong password" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt new file mode 100644 index 0000000000000..6ce29e67982c4 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PatternBouncerViewModelTest.kt @@ -0,0 +1,272 @@ +/* + * 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.shared.model.AuthenticationMethodModel +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.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 utils = SceneTestUtils(this, testScope) + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), + ) + private val sceneInteractor = utils.sceneInteractor() + private val bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ) + private val bouncerViewModel = + utils.bouncerViewModel( + bouncerInteractor = bouncerInteractor, + ) + 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(authenticationInteractor.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) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + authenticationInteractor.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(authenticationInteractor.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) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + authenticationInteractor.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val selectedDots by collectLastValue(underTest.selectedDots) + val currentDot by collectLastValue(underTest.currentDot) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + authenticationInteractor.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(authenticationInteractor.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) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + authenticationInteractor.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(authenticationInteractor.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) + authenticationInteractor.setAuthenticationMethod( + AuthenticationMethodModel.Pattern(CORRECT_PATTERN) + ) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onDragStart() + CORRECT_PATTERN.subList(2, 7).forEach { coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + } + underTest.onDragEnd() + assertThat(selectedDots).isEmpty() + assertThat(currentDot).isNull() + assertThat(message).isEqualTo(WRONG_PATTERN) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + // Enter the correct pattern: + CORRECT_PATTERN.forEach { coordinate -> + underTest.onDrag( + xPx = 30f * coordinate.x + 15, + yPx = 30f * coordinate.y + 15, + containerSizePx = 90, + verticalOffsetPx = 0f, + ) + } + + underTest.onDragEnd() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + companion object { + private const val CONTAINER_NAME = "container1" + private const val ENTER_YOUR_PATTERN = "Enter your pattern" + private const val WRONG_PATTERN = "Wrong pattern" + private val CORRECT_PATTERN = + listOf( + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 0), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 1), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 2, y = 2), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 1, y = 2), + AuthenticationMethodModel.Pattern.PatternCoordinate(x = 0, y = 2), + ) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt new file mode 100644 index 0000000000000..bb28520ad8a0e --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/bouncer/ui/viewmodel/PinBouncerViewModelTest.kt @@ -0,0 +1,267 @@ +/* + * 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.shared.model.AuthenticationMethodModel +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.shared.model.SceneKey +import com.android.systemui.scene.shared.model.SceneModel +import com.google.common.truth.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 utils = SceneTestUtils(this, testScope) + private val sceneInteractor = utils.sceneInteractor() + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), + ) + private val bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ) + 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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + + underTest.onShown() + + 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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + + underTest.onPinButtonClicked(1) + + 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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + 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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.onPinButtonClicked(4) + + underTest.onAuthenticateButtonClicked() + + assertThat(isUnlocked).isTrue() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) + } + + @Test + fun onAuthenticateButtonClicked_whenWrong() = + testScope.runTest { + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.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(authenticationInteractor.isUnlocked) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val message by collectLastValue(bouncerViewModel.message) + val pinLengths by collectLastValue(underTest.pinLengths) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Bouncer)) + assertThat(isUnlocked).isFalse() + assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) + underTest.onShown() + underTest.onPinButtonClicked(1) + underTest.onPinButtonClicked(2) + underTest.onPinButtonClicked(3) + underTest.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" + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt index 749e7a0481eb5..c2c528a9babfc 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/LockScreenSceneInteractorTest.kt @@ -18,14 +18,10 @@ package com.android.systemui.keyguard.domain.interactor 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.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.SceneTestUtils.Companion.CONTAINER_1 import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -43,34 +39,21 @@ import org.junit.runners.JUnit4 class LockScreenSceneInteractorTest : SysuiTestCase() { private val testScope = TestScope() - private val sceneInteractor = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) - private val mAuthenticationInteractor = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, - repository = AuthenticationRepositoryImpl(), + private val utils = SceneTestUtils(this, testScope) + private val sceneInteractor = utils.sceneInteractor() + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), ) private val underTest = - LockScreenSceneInteractor( - applicationScope = testScope.backgroundScope, - authenticationInteractor = mAuthenticationInteractor, - bouncerInteractorFactory = - object : BouncerInteractor.Factory { - override fun create(containerName: String): BouncerInteractor { - return BouncerInteractor( - applicationScope = testScope.backgroundScope, - applicationContext = context, - repository = BouncerRepository(), - authenticationInteractor = mAuthenticationInteractor, - sceneInteractor = sceneInteractor, - containerName = containerName, - ) - } - }, + utils.lockScreenSceneInteractor( + authenticationInteractor = authenticationInteractor, sceneInteractor = sceneInteractor, - containerName = CONTAINER_NAME, + bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ), ) @Test @@ -78,10 +61,10 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { testScope.runTest { val isDeviceLocked by collectLastValue(underTest.isDeviceLocked) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.lockDevice() assertThat(isDeviceLocked).isTrue() - mAuthenticationInteractor.unlockDevice() + authenticationInteractor.unlockDevice() assertThat(isDeviceLocked).isFalse() } @@ -90,8 +73,8 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { testScope.runTest { val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled) - mAuthenticationInteractor.lockDevice() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + authenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) assertThat(isSwipeToDismissEnabled).isTrue() } @@ -101,8 +84,8 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { testScope.runTest { val isSwipeToDismissEnabled by collectLastValue(underTest.isSwipeToDismissEnabled) - mAuthenticationInteractor.unlockDevice() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + authenticationInteractor.unlockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) assertThat(isSwipeToDismissEnabled).isFalse() } @@ -110,9 +93,9 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun dismissLockScreen_deviceLockedWithSecureAuthMethod_switchesToBouncer() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.lockDevice() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) underTest.dismissLockScreen() @@ -123,9 +106,9 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun dismissLockScreen_deviceUnlocked_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.unlockDevice() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.unlockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) underTest.dismissLockScreen() @@ -136,9 +119,9 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun dismissLockScreen_deviceLockedWithInsecureAuthMethod_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.lockDevice() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) underTest.dismissLockScreen() @@ -149,15 +132,15 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun deviceLockedInNonLockScreenScene_switchesToLockScreenScene() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) runCurrent() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.Gone)) runCurrent() - mAuthenticationInteractor.unlockDevice() + authenticationInteractor.unlockDevice() runCurrent() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.lockDevice() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) } @@ -165,15 +148,15 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun deviceBiometricUnlockedInLockScreen_bypassEnabled_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.lockDevice() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) - if (!mAuthenticationInteractor.isBypassEnabled.value) { - mAuthenticationInteractor.toggleBypassEnabled() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.LockScreen)) + if (!authenticationInteractor.isBypassEnabled.value) { + authenticationInteractor.toggleBypassEnabled() } assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.biometricUnlock() + authenticationInteractor.biometricUnlock() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } @@ -181,15 +164,15 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun deviceBiometricUnlockedInLockScreen_bypassNotEnabled_doesNotSwitch() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.lockDevice() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) - if (mAuthenticationInteractor.isBypassEnabled.value) { - mAuthenticationInteractor.toggleBypassEnabled() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.lockDevice() + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.LockScreen)) + if (authenticationInteractor.isBypassEnabled.value) { + authenticationInteractor.toggleBypassEnabled() } assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.biometricUnlock() + authenticationInteractor.biometricUnlock() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) } @@ -197,12 +180,12 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun switchFromLockScreenToGone_authMethodSwipe_unlocksDevice() = testScope.runTest { - val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.LockScreen)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) assertThat(isUnlocked).isFalse() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.Gone)) assertThat(isUnlocked).isTrue() } @@ -210,12 +193,12 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun switchFromLockScreenToGone_authMethodNotSwipe_doesNotUnlockDevice() = testScope.runTest { - val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.LockScreen)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) assertThat(isUnlocked).isFalse() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.Gone)) assertThat(isUnlocked).isFalse() } @@ -223,15 +206,15 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun switchFromNonLockScreenToGone_authMethodSwipe_doesNotUnlockDevice() = testScope.runTest { - val isUnlocked by collectLastValue(mAuthenticationInteractor.isUnlocked) + val isUnlocked by collectLastValue(authenticationInteractor.isUnlocked) runCurrent() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Shade)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.Shade)) runCurrent() - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) runCurrent() assertThat(isUnlocked).isFalse() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.Gone)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.Gone)) assertThat(isUnlocked).isFalse() } @@ -239,12 +222,12 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun authMethodChangedToNone_onLockScreenScene_dismissesLockScreen() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.LockScreen)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } @@ -252,19 +235,15 @@ class LockScreenSceneInteractorTest : SysuiTestCase() { @Test fun authMethodChangedToNone_notOnLockScreenScene_doesNotDismissLockScreen() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) runCurrent() - sceneInteractor.setCurrentScene(CONTAINER_NAME, SceneModel(SceneKey.QuickSettings)) + sceneInteractor.setCurrentScene(CONTAINER_1, SceneModel(SceneKey.QuickSettings)) runCurrent() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.None) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.QuickSettings)) } - - companion object { - private const val CONTAINER_NAME = "container1" - } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt index d335b09b196a1..9e8be3e7608f5 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/ui/viewmodel/LockScreenSceneViewModelTest.kt @@ -19,16 +19,12 @@ package com.android.systemui.keyguard.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.common.shared.model.Icon import com.android.systemui.coroutines.collectLastValue import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.SceneTestUtils.Companion.CONTAINER_1 import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -46,14 +42,11 @@ import org.junit.runners.JUnit4 class LockScreenSceneViewModelTest : SysuiTestCase() { private val testScope = TestScope() - private val sceneInteractor = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) - private val mAuthenticationInteractor = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, - repository = AuthenticationRepositoryImpl(), + private val utils = SceneTestUtils(this, testScope) + private val sceneInteractor = utils.sceneInteractor() + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), ) private val underTest = @@ -62,38 +55,28 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { interactorFactory = object : LockScreenSceneInteractor.Factory { override fun create(containerName: String): LockScreenSceneInteractor { - return LockScreenSceneInteractor( - applicationScope = testScope.backgroundScope, - authenticationInteractor = mAuthenticationInteractor, - bouncerInteractorFactory = - object : BouncerInteractor.Factory { - override fun create(containerName: String): BouncerInteractor { - return BouncerInteractor( - applicationScope = testScope.backgroundScope, - applicationContext = context, - repository = BouncerRepository(), - authenticationInteractor = mAuthenticationInteractor, - sceneInteractor = sceneInteractor, - containerName = containerName, - ) - } - }, + return utils.lockScreenSceneInteractor( + authenticationInteractor = authenticationInteractor, sceneInteractor = sceneInteractor, - containerName = CONTAINER_NAME, + bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ), ) } }, - containerName = CONTAINER_NAME + containerName = CONTAINER_1 ) @Test fun lockButtonIcon_whenLocked() = testScope.runTest { val lockButtonIcon by collectLastValue(underTest.lockButtonIcon) - mAuthenticationInteractor.setAuthenticationMethod( + authenticationInteractor.setAuthenticationMethod( AuthenticationMethodModel.Password("password") ) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.lockDevice() assertThat((lockButtonIcon as? Icon.Resource)?.res) .isEqualTo(R.drawable.ic_device_lock_on) @@ -103,10 +86,10 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { fun lockButtonIcon_whenUnlocked() = testScope.runTest { val lockButtonIcon by collectLastValue(underTest.lockButtonIcon) - mAuthenticationInteractor.setAuthenticationMethod( + authenticationInteractor.setAuthenticationMethod( AuthenticationMethodModel.Password("password") ) - mAuthenticationInteractor.unlockDevice() + authenticationInteractor.unlockDevice() assertThat((lockButtonIcon as? Icon.Resource)?.res) .isEqualTo(R.drawable.ic_device_lock_off) @@ -116,8 +99,8 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { fun upTransitionSceneKey_swipeToUnlockedEnabled_gone() = testScope.runTest { val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.Swipe) + authenticationInteractor.lockDevice() assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Gone) } @@ -126,8 +109,8 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { fun upTransitionSceneKey_swipeToUnlockedNotEnabled_bouncer() = testScope.runTest { val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Bouncer) } @@ -135,9 +118,9 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { @Test fun onLockButtonClicked_deviceLockedSecurely_switchesToBouncer() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() runCurrent() underTest.onLockButtonClicked() @@ -148,9 +131,9 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { @Test fun onContentClicked_deviceUnlocked_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.unlockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.unlockDevice() runCurrent() underTest.onContentClicked() @@ -161,9 +144,9 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { @Test fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() runCurrent() underTest.onContentClicked() @@ -174,17 +157,13 @@ class LockScreenSceneViewModelTest : SysuiTestCase() { @Test fun onLockButtonClicked_deviceUnlocked_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.unlockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.unlockDevice() runCurrent() underTest.onLockButtonClicked() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Gone)) } - - companion object { - private const val CONTAINER_NAME = "container1" - } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt index e8875bee5276d..3f838e6516f16 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/qs/ui/viewmodel/QuickSettingsSceneViewModelTest.kt @@ -18,15 +18,11 @@ package com.android.systemui.qs.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.keyguard.domain.interactor.LockScreenSceneInteractor -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.SceneTestUtils.Companion.CONTAINER_1 import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -44,14 +40,11 @@ import org.junit.runners.JUnit4 class QuickSettingsSceneViewModelTest : SysuiTestCase() { private val testScope = TestScope() - private val sceneInteractor = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) - private val mAuthenticationInteractor = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, - repository = AuthenticationRepositoryImpl(), + private val utils = SceneTestUtils(this, testScope) + private val sceneInteractor = utils.sceneInteractor() + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), ) private val underTest = @@ -59,36 +52,26 @@ class QuickSettingsSceneViewModelTest : SysuiTestCase() { lockScreenSceneInteractorFactory = object : LockScreenSceneInteractor.Factory { override fun create(containerName: String): LockScreenSceneInteractor { - return LockScreenSceneInteractor( - applicationScope = testScope.backgroundScope, - authenticationInteractor = mAuthenticationInteractor, - bouncerInteractorFactory = - object : BouncerInteractor.Factory { - override fun create(containerName: String): BouncerInteractor { - return BouncerInteractor( - applicationScope = testScope.backgroundScope, - applicationContext = context, - repository = BouncerRepository(), - authenticationInteractor = mAuthenticationInteractor, - sceneInteractor = sceneInteractor, - containerName = containerName, - ) - } - }, + return utils.lockScreenSceneInteractor( + authenticationInteractor = authenticationInteractor, sceneInteractor = sceneInteractor, - containerName = CONTAINER_NAME, + bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ), ) } }, - containerName = CONTAINER_NAME + containerName = CONTAINER_1 ) @Test fun onContentClicked_deviceUnlocked_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.unlockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.unlockDevice() runCurrent() underTest.onContentClicked() @@ -99,17 +82,13 @@ class QuickSettingsSceneViewModelTest : SysuiTestCase() { @Test fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() runCurrent() underTest.onContentClicked() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) } - - companion object { - private const val CONTAINER_NAME = "container1" - } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/Fakes.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/Fakes.kt deleted file mode 100644 index 1cdaec0c6581c..0000000000000 --- a/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/Fakes.kt +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.scene.data.repository - -import com.android.systemui.scene.data.model.SceneContainerConfig -import com.android.systemui.scene.shared.model.SceneKey - -fun fakeSceneContainerRepository( - containerConfigurations: Set = - setOf( - fakeSceneContainerConfig("container1"), - fakeSceneContainerConfig("container2"), - ) -): SceneContainerRepository { - return SceneContainerRepository(containerConfigurations) -} - -fun fakeSceneKeys(): List { - return listOf( - SceneKey.QuickSettings, - SceneKey.Shade, - SceneKey.LockScreen, - SceneKey.Bouncer, - SceneKey.Gone, - ) -} - -fun fakeSceneContainerConfig( - name: String, - sceneKeys: List = fakeSceneKeys(), -): SceneContainerConfig { - return SceneContainerConfig( - name = name, - sceneKeys = sceneKeys, - initialSceneKey = SceneKey.LockScreen, - ) -} diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/SceneContainerRepositoryTest.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/SceneContainerRepositoryTest.kt index 9e264db845e1c..6c7017bac68e7 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/SceneContainerRepositoryTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/scene/data/repository/SceneContainerRepositoryTest.kt @@ -21,6 +21,7 @@ package com.android.systemui.scene.data.repository import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -34,9 +35,11 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class SceneContainerRepositoryTest : SysuiTestCase() { + private val utils = SceneTestUtils(this) + @Test fun allSceneKeys() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() assertThat(underTest.allSceneKeys("container1")) .isEqualTo( listOf( @@ -51,13 +54,13 @@ class SceneContainerRepositoryTest : SysuiTestCase() { @Test(expected = IllegalStateException::class) fun allSceneKeys_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.allSceneKeys("nonExistingContainer") } @Test fun currentScene() = runTest { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() val currentScene by collectLastValue(underTest.currentScene("container1")) assertThat(currentScene).isEqualTo(SceneModel(SceneKey.LockScreen)) @@ -67,23 +70,23 @@ class SceneContainerRepositoryTest : SysuiTestCase() { @Test(expected = IllegalStateException::class) fun currentScene_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.currentScene("nonExistingContainer") } @Test(expected = IllegalStateException::class) fun setCurrentScene_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.setCurrentScene("nonExistingContainer", SceneModel(SceneKey.Shade)) } @Test(expected = IllegalStateException::class) fun setCurrentScene_noSuchSceneInContainer_throws() { val underTest = - fakeSceneContainerRepository( + utils.fakeSceneContainerRepository( setOf( - fakeSceneContainerConfig("container1"), - fakeSceneContainerConfig( + utils.fakeSceneContainerConfig("container1"), + utils.fakeSceneContainerConfig( "container2", listOf(SceneKey.QuickSettings, SceneKey.LockScreen) ), @@ -94,7 +97,7 @@ class SceneContainerRepositoryTest : SysuiTestCase() { @Test fun isVisible() = runTest { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() val isVisible by collectLastValue(underTest.isVisible("container1")) assertThat(isVisible).isTrue() @@ -107,19 +110,19 @@ class SceneContainerRepositoryTest : SysuiTestCase() { @Test(expected = IllegalStateException::class) fun isVisible_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.isVisible("nonExistingContainer") } @Test(expected = IllegalStateException::class) fun setVisible_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.setVisible("nonExistingContainer", false) } @Test fun sceneTransitionProgress() = runTest { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() val sceneTransitionProgress by collectLastValue(underTest.sceneTransitionProgress("container1")) assertThat(sceneTransitionProgress).isEqualTo(1f) @@ -133,7 +136,7 @@ class SceneContainerRepositoryTest : SysuiTestCase() { @Test(expected = IllegalStateException::class) fun sceneTransitionProgress_noSuchContainer_throws() { - val underTest = fakeSceneContainerRepository() + val underTest = utils.fakeSceneContainerRepository() underTest.sceneTransitionProgress("nonExistingContainer") } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/domain/interactor/SceneInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/domain/interactor/SceneInteractorTest.kt index c5ce092468627..cf99e3b09b32b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/scene/domain/interactor/SceneInteractorTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/scene/domain/interactor/SceneInteractorTest.kt @@ -21,8 +21,7 @@ package com.android.systemui.scene.domain.interactor import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.data.repository.fakeSceneKeys +import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -36,14 +35,12 @@ import org.junit.runners.JUnit4 @RunWith(JUnit4::class) class SceneInteractorTest : SysuiTestCase() { - private val underTest = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) + private val utils = SceneTestUtils(this) + private val underTest = utils.sceneInteractor() @Test fun allSceneKeys() { - assertThat(underTest.allSceneKeys("container1")).isEqualTo(fakeSceneKeys()) + assertThat(underTest.allSceneKeys("container1")).isEqualTo(utils.fakeSceneKeys()) } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt index ab61ddddaeab5..6105c87357149 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/scene/ui/viewmodel/SceneContainerViewModelTest.kt @@ -21,9 +21,7 @@ package com.android.systemui.scene.ui.viewmodel import androidx.test.filters.SmallTest import com.android.systemui.SysuiTestCase import com.android.systemui.coroutines.collectLastValue -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.data.repository.fakeSceneKeys -import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.SceneTestUtils import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -36,10 +34,9 @@ import org.junit.runners.JUnit4 @SmallTest @RunWith(JUnit4::class) class SceneContainerViewModelTest : SysuiTestCase() { - private val interactor = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) + + private val utils = SceneTestUtils(this) + private val interactor = utils.sceneInteractor() private val underTest = SceneContainerViewModel( interactor = interactor, @@ -60,7 +57,7 @@ class SceneContainerViewModelTest : SysuiTestCase() { @Test fun allSceneKeys() { - assertThat(underTest.allSceneKeys).isEqualTo(fakeSceneKeys()) + assertThat(underTest.allSceneKeys).isEqualTo(utils.fakeSceneKeys()) } @Test diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt index 688cce83a55d3..2e7f83d597f2c 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/ui/viewmodel/ShadeSceneViewModelTest.kt @@ -18,15 +18,11 @@ package com.android.systemui.shade.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.keyguard.domain.interactor.LockScreenSceneInteractor -import com.android.systemui.scene.data.repository.fakeSceneContainerRepository -import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.SceneTestUtils +import com.android.systemui.scene.SceneTestUtils.Companion.CONTAINER_1 import com.android.systemui.scene.shared.model.SceneKey import com.android.systemui.scene.shared.model.SceneModel import com.google.common.truth.Truth.assertThat @@ -44,14 +40,11 @@ import org.junit.runners.JUnit4 class ShadeSceneViewModelTest : SysuiTestCase() { private val testScope = TestScope() - private val sceneInteractor = - SceneInteractor( - repository = fakeSceneContainerRepository(), - ) - private val mAuthenticationInteractor = - AuthenticationInteractor( - applicationScope = testScope.backgroundScope, - repository = AuthenticationRepositoryImpl(), + private val utils = SceneTestUtils(this, testScope) + private val sceneInteractor = utils.sceneInteractor() + private val authenticationInteractor = + utils.authenticationInteractor( + repository = utils.authenticationRepository(), ) private val underTest = @@ -60,36 +53,26 @@ class ShadeSceneViewModelTest : SysuiTestCase() { lockScreenSceneInteractorFactory = object : LockScreenSceneInteractor.Factory { override fun create(containerName: String): LockScreenSceneInteractor { - return LockScreenSceneInteractor( - applicationScope = testScope.backgroundScope, - authenticationInteractor = mAuthenticationInteractor, - bouncerInteractorFactory = - object : BouncerInteractor.Factory { - override fun create(containerName: String): BouncerInteractor { - return BouncerInteractor( - applicationScope = testScope.backgroundScope, - applicationContext = context, - repository = BouncerRepository(), - authenticationInteractor = mAuthenticationInteractor, - sceneInteractor = sceneInteractor, - containerName = containerName, - ) - } - }, + return utils.lockScreenSceneInteractor( + authenticationInteractor = authenticationInteractor, sceneInteractor = sceneInteractor, - containerName = CONTAINER_NAME, + bouncerInteractor = + utils.bouncerInteractor( + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + ), ) } }, - containerName = CONTAINER_NAME + containerName = SceneTestUtils.CONTAINER_1 ) @Test fun upTransitionSceneKey_deviceLocked_lockScreen() = testScope.runTest { val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() assertThat(upTransitionSceneKey).isEqualTo(SceneKey.LockScreen) } @@ -98,8 +81,8 @@ class ShadeSceneViewModelTest : SysuiTestCase() { fun upTransitionSceneKey_deviceUnlocked_gone() = testScope.runTest { val upTransitionSceneKey by collectLastValue(underTest.upDestinationSceneKey) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.unlockDevice() + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.unlockDevice() assertThat(upTransitionSceneKey).isEqualTo(SceneKey.Gone) } @@ -107,9 +90,9 @@ class ShadeSceneViewModelTest : SysuiTestCase() { @Test fun onContentClicked_deviceUnlocked_switchesToGone() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.unlockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.unlockDevice() runCurrent() underTest.onContentClicked() @@ -120,17 +103,13 @@ class ShadeSceneViewModelTest : SysuiTestCase() { @Test fun onContentClicked_deviceLockedSecurely_switchesToBouncer() = testScope.runTest { - val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_NAME)) - mAuthenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) - mAuthenticationInteractor.lockDevice() + val currentScene by collectLastValue(sceneInteractor.currentScene(CONTAINER_1)) + authenticationInteractor.setAuthenticationMethod(AuthenticationMethodModel.PIN(1234)) + authenticationInteractor.lockDevice() runCurrent() underTest.onContentClicked() assertThat(currentScene).isEqualTo(SceneModel(SceneKey.Bouncer)) } - - companion object { - private const val CONTAINER_NAME = "container1" - } } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt new file mode 100644 index 0000000000000..5a350bb540b75 --- /dev/null +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/scene/SceneTestUtils.kt @@ -0,0 +1,161 @@ +/* + * 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.scene + +import com.android.systemui.SysuiTestCase +import com.android.systemui.authentication.data.repository.AuthenticationRepository +import com.android.systemui.authentication.data.repository.AuthenticationRepositoryImpl +import com.android.systemui.authentication.domain.interactor.AuthenticationInteractor +import com.android.systemui.bouncer.data.repo.BouncerRepository +import com.android.systemui.bouncer.domain.interactor.BouncerInteractor +import com.android.systemui.bouncer.ui.viewmodel.BouncerViewModel +import com.android.systemui.keyguard.domain.interactor.LockScreenSceneInteractor +import com.android.systemui.scene.data.model.SceneContainerConfig +import com.android.systemui.scene.data.repository.SceneContainerRepository +import com.android.systemui.scene.domain.interactor.SceneInteractor +import com.android.systemui.scene.shared.model.SceneKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope + +/** + * Utilities for creating scene container framework related repositories, interactors, and + * view-models for tests. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SceneTestUtils( + test: SysuiTestCase, + private val testScope: TestScope? = null, +) { + + private val context = test.context + + fun fakeSceneContainerRepository( + containerConfigurations: Set = + setOf( + fakeSceneContainerConfig(CONTAINER_1), + fakeSceneContainerConfig(CONTAINER_2), + ) + ): SceneContainerRepository { + return SceneContainerRepository(containerConfigurations) + } + + fun fakeSceneKeys(): List { + return listOf( + SceneKey.QuickSettings, + SceneKey.Shade, + SceneKey.LockScreen, + SceneKey.Bouncer, + SceneKey.Gone, + ) + } + + fun fakeSceneContainerConfig( + name: String, + sceneKeys: List = fakeSceneKeys(), + ): SceneContainerConfig { + return SceneContainerConfig( + name = name, + sceneKeys = sceneKeys, + initialSceneKey = SceneKey.LockScreen, + ) + } + + fun sceneInteractor(): SceneInteractor { + return SceneInteractor( + repository = fakeSceneContainerRepository(), + ) + } + + fun authenticationRepository(): AuthenticationRepository { + return AuthenticationRepositoryImpl() + } + + fun authenticationInteractor( + repository: AuthenticationRepository, + ): AuthenticationInteractor { + return AuthenticationInteractor( + applicationScope = applicationScope(), + repository = repository, + ) + } + + private fun applicationScope(): CoroutineScope { + return checkNotNull(testScope) { + """ + TestScope not initialized, please create a TestScope and inject it into + SceneTestUtils. + """ + .trimIndent() + } + .backgroundScope + } + + fun bouncerInteractor( + authenticationInteractor: AuthenticationInteractor, + sceneInteractor: SceneInteractor, + ): BouncerInteractor { + return BouncerInteractor( + applicationScope = applicationScope(), + applicationContext = context, + repository = BouncerRepository(), + authenticationInteractor = authenticationInteractor, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_1, + ) + } + + fun bouncerViewModel( + bouncerInteractor: BouncerInteractor, + ): BouncerViewModel { + return BouncerViewModel( + applicationContext = context, + applicationScope = applicationScope(), + interactorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return bouncerInteractor + } + }, + containerName = CONTAINER_1, + ) + } + + fun lockScreenSceneInteractor( + authenticationInteractor: AuthenticationInteractor, + sceneInteractor: SceneInteractor, + bouncerInteractor: BouncerInteractor, + ): LockScreenSceneInteractor { + return LockScreenSceneInteractor( + applicationScope = applicationScope(), + authenticationInteractor = authenticationInteractor, + bouncerInteractorFactory = + object : BouncerInteractor.Factory { + override fun create(containerName: String): BouncerInteractor { + return bouncerInteractor + } + }, + sceneInteractor = sceneInteractor, + containerName = CONTAINER_1, + ) + } + + companion object { + const val CONTAINER_1 = "container1" + const val CONTAINER_2 = "container2" + } +}