PM-10621: Add the SetupUnlockScreen (#3699)

This commit is contained in:
David Perez 2024-08-08 16:18:29 -05:00 committed by GitHub
parent 6bb5ef7417
commit 145f8adf0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1179 additions and 0 deletions

View file

@ -0,0 +1,30 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavOptions
import com.x8bit.bitwarden.ui.platform.base.util.composableWithPushTransitions
private const val SETUP_UNLOCK_ROUTE = "setup_unlock"
/**
* Navigate to the setup unlock screen.
*/
fun NavController.navigateToSetupUnlockScreen(navOptions: NavOptions? = null) {
this.navigate(SETUP_UNLOCK_ROUTE, navOptions)
}
/**
* Add the setup unlock screen to the nav graph.
*/
fun NavGraphBuilder.setupUnlockDestination(
onNavigateToSetupAutofill: () -> Unit,
) {
composableWithPushTransitions(
route = SETUP_UNLOCK_ROUTE,
) {
SetupUnlockScreen(
onNavigateToSetupAutofill = onNavigateToSetupAutofill,
)
}
}

View file

@ -0,0 +1,272 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers.SetupUnlockHandler
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenFilledButton
import com.x8bit.bitwarden.ui.platform.components.button.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.scaffold.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithBiometricsSwitch
import com.x8bit.bitwarden.ui.platform.components.toggle.BitwardenUnlockWithPinSwitch
import com.x8bit.bitwarden.ui.platform.components.util.rememberVectorPainter
import com.x8bit.bitwarden.ui.platform.composition.LocalBiometricsManager
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.platform.util.isPortrait
/**
* Top level composable for the setup unlock screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SetupUnlockScreen(
onNavigateToSetupAutofill: () -> Unit,
viewModel: SetupUnlockViewModel = hiltViewModel(),
biometricsManager: BiometricsManager = LocalBiometricsManager.current,
) {
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val handler = remember(viewModel) { SetupUnlockHandler.create(viewModel = viewModel) }
EventsEffect(viewModel = viewModel) { event ->
when (event) {
SetupUnlockEvent.NavigateToSetupAutofill -> onNavigateToSetupAutofill()
}
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
.fillMaxSize()
.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
BitwardenTopAppBar(
title = stringResource(id = R.string.account_setup),
scrollBehavior = scrollBehavior,
navigationIcon = null,
)
},
) { innerPadding ->
SetupUnlockScreenContent(
state = state,
handler = handler,
biometricsManager = biometricsManager,
modifier = Modifier
.padding(paddingValues = innerPadding)
.fillMaxSize(),
)
}
}
@Composable
private fun SetupUnlockScreenContent(
state: SetupUnlockState,
handler: SetupUnlockHandler,
modifier: Modifier = Modifier,
biometricsManager: BiometricsManager,
config: Configuration = LocalConfiguration.current,
) {
val marginHorizontal = remember(config.orientation) {
if (config.isPortrait) 16.dp else 48.dp
}
Column(
modifier = modifier.verticalScroll(state = rememberScrollState()),
) {
if (config.isPortrait) {
SetupUnlockHeaderPortrait()
} else {
SetupUnlockHeaderLandscape()
}
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenUnlockWithBiometricsSwitch(
isBiometricsSupported = biometricsManager.isBiometricsSupported,
isChecked = state.isUnlockWithBiometricsEnabled,
onDisableBiometrics = handler.onDisableBiometrics,
onEnableBiometrics = handler.onEnableBiometrics,
modifier = Modifier
.testTag(tag = "UnlockWithBiometricsSwitch")
.fillMaxWidth()
.padding(horizontal = marginHorizontal),
)
BitwardenUnlockWithPinSwitch(
isUnlockWithPasswordEnabled = state.isUnlockWithPasswordEnabled,
isUnlockWithPinEnabled = state.isUnlockWithPinEnabled,
onUnlockWithPinToggleAction = handler.onUnlockWithPinToggle,
modifier = Modifier
.testTag(tag = "UnlockWithPinSwitch")
.fillMaxWidth()
.padding(horizontal = marginHorizontal),
)
Spacer(modifier = Modifier.height(height = 24.dp))
BitwardenFilledButton(
label = stringResource(id = R.string.continue_text),
onClick = handler.onContinueClick,
isEnabled = state.isContinueButtonEnabled,
modifier = Modifier
.testTag(tag = "ContinueButton")
.fillMaxWidth()
.padding(horizontal = marginHorizontal),
)
Spacer(modifier = Modifier.height(height = 12.dp))
SetUpLaterButton(
onConfirmClick = handler.onSetUpLaterClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = marginHorizontal),
)
Spacer(modifier = Modifier.height(height = 12.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
@Composable
private fun SetUpLaterButton(
modifier: Modifier,
onConfirmClick: () -> Unit,
) {
var displayConfirmation by rememberSaveable { mutableStateOf(value = false) }
if (displayConfirmation) {
@Suppress("MaxLineLength")
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.set_up_unlock_later),
message = stringResource(
id = R.string.you_can_return_to_complete_this_step_anytime_from_account_security_in_settings,
),
confirmButtonText = stringResource(id = R.string.confirm),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
onConfirmClick()
displayConfirmation = false
},
onDismissClick = { displayConfirmation = false },
onDismissRequest = { displayConfirmation = false },
)
}
BitwardenTextButton(
label = stringResource(id = R.string.set_up_later),
onClick = { displayConfirmation = true },
modifier = modifier.testTag(tag = "SetUpLaterButton"),
)
}
@Composable
private fun ColumnScope.SetupUnlockHeaderPortrait() {
Spacer(modifier = Modifier.height(height = 32.dp))
Image(
painter = rememberVectorPainter(id = R.drawable.account_setup),
contentDescription = null,
modifier = Modifier
.padding(horizontal = 16.dp)
.size(size = 100.dp)
.align(alignment = Alignment.CenterHorizontally),
)
Spacer(modifier = Modifier.height(height = 24.dp))
Text(
text = stringResource(id = R.string.set_up_unlock),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(height = 8.dp))
@Suppress("MaxLineLength")
Text(
text = stringResource(
id = R.string.set_up_biometrics_or_choose_a_pin_code_to_quickly_access_your_vault_and_autofill_your_logins,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
}
@Composable
private fun SetupUnlockHeaderLandscape(
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.padding(horizontal = 112.dp)
.padding(horizontal = 48.dp),
) {
Image(
painter = rememberVectorPainter(id = R.drawable.account_setup),
contentDescription = null,
modifier = Modifier
.size(size = 100.dp)
.align(alignment = Alignment.CenterVertically),
)
Spacer(modifier = Modifier.width(width = 24.dp))
Column(
modifier = Modifier.align(alignment = Alignment.CenterVertically),
) {
Text(
text = stringResource(id = R.string.set_up_unlock),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(height = 8.dp))
@Suppress("MaxLineLength")
Text(
text = stringResource(
id = R.string.set_up_biometrics_or_choose_a_pin_code_to_quickly_access_your_vault_and_autofill_your_logins,
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
}
}

View file

@ -0,0 +1,138 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
private const val KEY_STATE = "state"
/**
* Models logic for the setup unlock screen.
*/
@HiltViewModel
class SetupUnlockViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val authRepository: AuthRepository,
private val settingsRepository: SettingsRepository,
private val biometricsEncryptionManager: BiometricsEncryptionManager,
) : BaseViewModel<SetupUnlockState, SetupUnlockEvent, SetupUnlockAction>(
initialState = savedStateHandle[KEY_STATE] ?: run {
val userId = requireNotNull(authRepository.userStateFlow.value).activeUserId
val isBiometricsValid = biometricsEncryptionManager.isBiometricIntegrityValid(
userId = userId,
cipher = biometricsEncryptionManager.getOrCreateCipher(userId = userId),
)
SetupUnlockState(
isUnlockWithPasswordEnabled = authRepository
.userStateFlow
.value
?.activeAccount
?.hasMasterPassword != false,
isUnlockWithPinEnabled = settingsRepository.isUnlockWithPinEnabled,
isUnlockWithBiometricsEnabled = settingsRepository.isUnlockWithBiometricsEnabled &&
isBiometricsValid,
)
},
) {
override fun handleAction(action: SetupUnlockAction) {
when (action) {
SetupUnlockAction.ContinueClick -> handleContinueClick()
SetupUnlockAction.EnableBiometricsClick -> handleEnableBiometricsClick()
SetupUnlockAction.SetUpLaterClick -> handleSetUpLaterClick()
is SetupUnlockAction.UnlockWithBiometricToggle -> {
handleUnlockWithBiometricToggle(action)
}
is SetupUnlockAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
}
}
private fun handleContinueClick() {
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
}
private fun handleEnableBiometricsClick() {
// TODO: Handle biometric unlocking logic PM-10624
}
private fun handleSetUpLaterClick() {
sendEvent(SetupUnlockEvent.NavigateToSetupAutofill)
}
private fun handleUnlockWithBiometricToggle(
action: SetupUnlockAction.UnlockWithBiometricToggle,
) {
// TODO: Handle biometric unlocking logic PM-10624
}
private fun handleUnlockWithPinToggle(action: SetupUnlockAction.UnlockWithPinToggle) {
// TODO: Handle pin unlocking logic PM-10628
}
}
/**
* Represents the UI state for the setup unlock screen.
*/
@Parcelize
data class SetupUnlockState(
val isUnlockWithPasswordEnabled: Boolean,
val isUnlockWithPinEnabled: Boolean,
val isUnlockWithBiometricsEnabled: Boolean,
) : Parcelable {
/**
* Indicates whether the continue button should be enabled or disabled.
*/
val isContinueButtonEnabled: Boolean
get() = isUnlockWithBiometricsEnabled || isUnlockWithPinEnabled
}
/**
* Models events for the setup unlock screen.
*/
sealed class SetupUnlockEvent {
/**
* Navigate to autofill setup.
*/
data object NavigateToSetupAutofill : SetupUnlockEvent()
}
/**
* Models action for the setup unlock screen.
*/
sealed class SetupUnlockAction {
/**
* User toggled the unlock with biometrics switch.
*/
data class UnlockWithBiometricToggle(
val isEnabled: Boolean,
) : SetupUnlockAction()
/**
* The user clicked to enable biometrics.
*/
data object EnableBiometricsClick : SetupUnlockAction()
/**
* User toggled the unlock with pin switch.
*/
data class UnlockWithPinToggle(
val state: UnlockWithPinState,
) : SetupUnlockAction()
/**
* The user clicked the continue button.
*/
data object ContinueClick : SetupUnlockAction()
/**
* The user clicked the set up later button.
*/
data object SetUpLaterClick : SetupUnlockAction()
}

View file

@ -0,0 +1,40 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup.handlers
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockAction
import com.x8bit.bitwarden.ui.auth.feature.accountsetup.SetupUnlockViewModel
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
/**
* A collection of handler functions for managing actions within the context of the Setup Unlock
* Screen.
*/
data class SetupUnlockHandler(
val onDisableBiometrics: () -> Unit,
val onEnableBiometrics: () -> Unit,
val onUnlockWithPinToggle: (UnlockWithPinState) -> Unit,
val onContinueClick: () -> Unit,
val onSetUpLaterClick: () -> Unit,
) {
companion object {
/**
* Creates an instance of [SetupUnlockHandler] by binding actions to the provided
* [SetupUnlockViewModel].
*/
fun create(viewModel: SetupUnlockViewModel): SetupUnlockHandler =
SetupUnlockHandler(
onDisableBiometrics = {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false),
)
},
onEnableBiometrics = {
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
},
onUnlockWithPinToggle = {
viewModel.trySendAction(SetupUnlockAction.UnlockWithPinToggle(it))
},
onContinueClick = { viewModel.trySendAction(SetupUnlockAction.ContinueClick) },
onSetUpLaterClick = { viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick) },
)
}
}

View file

@ -0,0 +1,58 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportHeight="200"
android:viewportWidth="200">
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M21,16.62C21,9.65 26.65,4 33.62,4H93.31C100.29,4 105.94,9.65 105.94,16.62V41.26C105.94,42.36 105.04,43.26 103.94,43.26C102.83,43.26 101.94,42.36 101.94,41.26V16.62C101.94,11.86 98.08,8 93.31,8H33.62C28.86,8 25,11.86 25,16.62V146.52C25,151.29 28.86,155.14 33.62,155.14H93.59C94.7,155.14 95.59,156.04 95.59,157.14C95.59,158.25 94.7,159.14 93.59,159.14H33.62C26.65,159.14 21,153.49 21,146.52V16.62Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M59.42,22.21C59.42,21.11 60.31,20.21 61.42,20.21H65.51C66.61,20.21 67.51,21.11 67.51,22.21C67.51,23.32 66.61,24.21 65.51,24.21H61.42C60.31,24.21 59.42,23.32 59.42,22.21Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M105.7,44.86C100.94,44.86 97.08,48.71 97.08,53.48V183.38C97.08,188.14 100.94,192 105.7,192H165.38C170.14,192 174,188.14 174,183.38V53.48C174,48.71 170.14,44.86 165.38,44.86H105.7ZM93.08,53.48C93.08,46.5 98.73,40.86 105.7,40.86H165.38C172.35,40.86 178,46.5 178,53.48V183.38C178,190.35 172.35,196 165.38,196H105.7C98.73,196 93.08,190.35 93.08,183.38V53.48Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M130.14,59.07C130.14,57.97 131.04,57.07 132.14,57.07H136.41C137.51,57.07 138.41,57.97 138.41,59.07C138.41,60.18 137.51,61.07 136.41,61.07H132.14C131.04,61.07 130.14,60.18 130.14,59.07Z" />
<path
android:fillColor="#6FD9E2"
android:fillType="evenOdd"
android:pathData="M77.46,58.89C77.46,57.79 78.35,56.89 79.46,56.89H83.24C85.55,56.89 87.6,58.71 87.6,61.14V64.73C87.6,65.84 86.7,66.73 85.6,66.73C84.49,66.73 83.6,65.84 83.6,64.73V61.14C83.6,61.11 83.59,61.06 83.52,61C83.46,60.94 83.36,60.89 83.24,60.89H79.46C78.35,60.89 77.46,60 77.46,58.89ZM36.42,61.15C36.42,58.71 38.45,56.91 40.77,56.91H44.55C45.66,56.91 46.55,57.8 46.55,58.91C46.55,60.01 45.66,60.91 44.55,60.91H40.77C40.63,60.91 40.54,60.96 40.48,61.01C40.43,61.06 40.42,61.11 40.42,61.15V64.75C40.42,65.85 39.52,66.75 38.42,66.75C37.31,66.75 36.42,65.85 36.42,64.75V61.15Z" />
<path
android:fillColor="#6FD9E2"
android:fillType="evenOdd"
android:pathData="M38.42,103.91C39.52,103.91 40.42,104.8 40.42,105.91V109.49C40.42,109.51 40.43,109.57 40.49,109.63C40.55,109.69 40.65,109.73 40.77,109.73H44.55C45.66,109.73 46.55,110.63 46.55,111.73C46.55,112.84 45.66,113.73 44.55,113.73H40.77C38.46,113.73 36.42,111.91 36.42,109.49V105.91C36.42,104.8 37.31,103.91 38.42,103.91ZM85.6,103.91C86.7,103.91 87.6,104.8 87.6,105.91V109.49C87.6,111.9 85.57,113.73 83.24,113.73H79.46C78.35,113.73 77.46,112.84 77.46,111.73C77.46,110.63 78.35,109.73 79.46,109.73H83.24C83.37,109.73 83.47,109.68 83.53,109.63C83.58,109.57 83.6,109.52 83.6,109.49V105.91C83.6,104.8 84.49,103.91 85.6,103.91Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M62.15,70.01C57.23,70.01 53.38,72.96 53.38,76.96V79.51C53.38,79.51 53.38,79.52 53.38,79.52C53.38,79.62 53.38,80.05 53.18,80.51C53.08,80.75 52.95,80.93 52.87,81.02C52.82,81.09 52.77,81.15 52.74,81.18C52.71,81.21 52.68,81.25 52.65,81.28C52.64,81.3 52.62,81.32 52.61,81.33C52.55,81.4 52.48,81.48 52.4,81.58C52.37,81.62 52.36,81.64 52.35,81.66C52.34,81.67 52.33,81.7 52.33,81.76C52.33,81.78 52.32,81.8 52.32,81.82C52.31,81.94 52.32,82.21 52.53,82.79C53.3,84.95 54.78,87.61 56.59,89.73C58.44,91.88 60.41,93.21 62.15,93.21C63,93.21 63.97,92.89 64.97,92.26L64.97,92.25C65.98,91.62 66.98,90.72 67.93,89.64C69.69,87.61 71.03,85.14 71.52,83.17L71.09,80.86C71.05,80.79 71.01,80.73 70.98,80.66C70.88,80.46 70.81,80.25 70.78,80.03C70.78,80.02 70.78,80 70.77,79.98C70.76,79.93 70.76,79.87 70.75,79.8C70.75,79.73 70.74,79.66 70.74,79.58V78.87C70.74,78.77 70.75,78.68 70.76,78.59L70.7,76.76C70.43,74.49 69.48,72.84 68.07,71.74C66.63,70.62 64.62,70.01 62.15,70.01ZM70.77,79.78L70.77,79.78L70.77,79.78L70.77,79.79C70.77,79.79 70.77,79.78 70.77,79.78ZM75.47,83.81C75.53,83.64 75.56,83.46 75.57,83.28C75.69,82.52 75.73,81.77 75.62,81.07C75.53,80.45 75.34,79.94 75.1,79.53L74.69,76.38C74.32,73.13 72.9,70.43 70.53,68.59L70.53,68.59C68.2,66.78 65.24,66.01 62.15,66.01C56.06,66.01 49.38,69.84 49.38,76.96V78.96C49.35,78.99 49.32,79.02 49.3,79.05L49.3,79.06C48.85,79.61 48.43,80.39 48.34,81.42C48.24,82.39 48.47,83.31 48.76,84.13L48.76,84.13C49.69,86.75 51.42,89.84 53.55,92.33L53.55,92.33C55.56,94.67 58.56,97.21 62.15,97.21C63.98,97.21 65.68,96.54 67.1,95.64C68.53,94.75 69.82,93.54 70.94,92.28L70.94,92.27C73.08,89.81 74.86,86.65 75.47,83.81ZM49.38,79.52C49.38,79.52 49.38,79.52 49.38,79.52L49.38,79.52Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M56.68,91.31C57.92,91.17 59.05,92.07 59.18,93.32C59.29,94.28 59.33,95.8 59.14,97.39C58.96,98.93 58.52,100.84 57.44,102.34C56.71,103.36 55.29,103.59 54.28,102.86C53.26,102.13 53.02,100.71 53.76,99.69C54.16,99.12 54.48,98.12 54.63,96.85C54.78,95.63 54.74,94.46 54.67,93.81C54.53,92.56 55.43,91.44 56.68,91.31Z" />
<path
android:fillColor="#E2E3E4"
android:fillType="evenOdd"
android:pathData="M68.12,91.31C69.37,91.44 70.27,92.56 70.13,93.81C70.06,94.46 70.02,95.63 70.17,96.85C70.32,98.12 70.64,99.12 71.04,99.69C71.78,100.71 71.54,102.13 70.52,102.86C69.51,103.59 68.09,103.36 67.36,102.34C66.28,100.84 65.84,98.93 65.66,97.39C65.47,95.8 65.51,94.28 65.62,93.32C65.75,92.07 66.88,91.17 68.12,91.31Z" />
<path
android:fillColor="#6FD9E2"
android:fillType="evenOdd"
android:pathData="M104.11,118.56C104.11,110.43 110.72,103.86 118.83,103.86H152.25C160.39,103.86 166.98,110.45 166.98,118.56C166.98,126.68 160.37,133.25 152.25,133.25H118.83C110.7,133.25 104.11,126.66 104.11,118.56ZM118.83,107.86C112.91,107.86 108.11,112.65 108.11,118.56C108.11,124.45 112.9,129.25 118.83,129.25H152.25C158.18,129.25 162.98,124.46 162.98,118.56C162.98,112.67 158.19,107.86 152.25,107.86H118.83Z" />
<path
android:fillColor="#E2E3E4"
android:pathData="M119.37,122.22C121.4,122.22 123.04,120.58 123.04,118.55C123.04,116.53 121.4,114.89 119.37,114.89C117.33,114.89 115.69,116.53 115.69,118.55C115.69,120.58 117.33,122.22 119.37,122.22Z" />
<path
android:fillColor="#E2E3E4"
android:pathData="M129.66,122.22C131.7,122.22 133.34,120.58 133.34,118.55C133.34,116.53 131.7,114.89 129.66,114.89C127.63,114.89 125.99,116.53 125.99,118.55C125.99,120.58 127.63,122.22 129.66,122.22Z" />
<path
android:fillColor="#E2E3E4"
android:pathData="M139.98,122.22C142.01,122.22 143.66,120.58 143.66,118.55C143.66,116.53 142.01,114.89 139.98,114.89C137.95,114.89 136.3,116.53 136.3,118.55C136.3,120.58 137.95,122.22 139.98,122.22Z" />
<path
android:fillColor="#E2E3E4"
android:pathData="M150.27,122.22C152.3,122.22 153.95,120.58 153.95,118.55C153.95,116.53 152.3,114.89 150.27,114.89C148.24,114.89 146.59,116.53 146.59,118.55C146.59,120.58 148.24,122.22 150.27,122.22Z" />
</vector>

View file

@ -0,0 +1,58 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportHeight="200"
android:viewportWidth="200">
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M21,16.62C21,9.65 26.65,4 33.62,4H93.31C100.29,4 105.94,9.65 105.94,16.62V41.26C105.94,42.36 105.04,43.26 103.94,43.26C102.83,43.26 101.94,42.36 101.94,41.26V16.62C101.94,11.86 98.08,8 93.31,8H33.62C28.86,8 25,11.86 25,16.62V146.52C25,151.29 28.86,155.14 33.62,155.14H93.59C94.7,155.14 95.59,156.04 95.59,157.14C95.59,158.25 94.7,159.14 93.59,159.14H33.62C26.65,159.14 21,153.49 21,146.52V16.62Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M59.42,22.21C59.42,21.11 60.31,20.21 61.42,20.21H65.51C66.61,20.21 67.51,21.11 67.51,22.21C67.51,23.32 66.61,24.21 65.51,24.21H61.42C60.31,24.21 59.42,23.32 59.42,22.21Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M105.7,44.86C100.94,44.86 97.08,48.71 97.08,53.48V183.38C97.08,188.14 100.94,192 105.7,192H165.38C170.14,192 174,188.14 174,183.38V53.48C174,48.71 170.14,44.86 165.38,44.86H105.7ZM93.08,53.48C93.08,46.5 98.73,40.86 105.7,40.86H165.38C172.35,40.86 178,46.5 178,53.48V183.38C178,190.35 172.35,196 165.38,196H105.7C98.73,196 93.08,190.35 93.08,183.38V53.48Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M130.14,59.07C130.14,57.97 131.04,57.07 132.14,57.07H136.41C137.51,57.07 138.41,57.97 138.41,59.07C138.41,60.18 137.51,61.07 136.41,61.07H132.14C131.04,61.07 130.14,60.18 130.14,59.07Z" />
<path
android:fillColor="#10949D"
android:fillType="evenOdd"
android:pathData="M77.46,58.89C77.46,57.79 78.35,56.89 79.46,56.89H83.24C85.55,56.89 87.6,58.71 87.6,61.14V64.73C87.6,65.84 86.7,66.73 85.6,66.73C84.49,66.73 83.6,65.84 83.6,64.73V61.14C83.6,61.11 83.59,61.06 83.52,61C83.46,60.94 83.36,60.89 83.24,60.89H79.46C78.35,60.89 77.46,60 77.46,58.89ZM36.42,61.15C36.42,58.71 38.45,56.91 40.77,56.91H44.55C45.66,56.91 46.55,57.8 46.55,58.91C46.55,60.01 45.66,60.91 44.55,60.91H40.77C40.63,60.91 40.54,60.96 40.48,61.01C40.43,61.06 40.42,61.11 40.42,61.15V64.75C40.42,65.85 39.52,66.75 38.42,66.75C37.31,66.75 36.42,65.85 36.42,64.75V61.15Z" />
<path
android:fillColor="#10949D"
android:fillType="evenOdd"
android:pathData="M38.42,103.91C39.52,103.91 40.42,104.8 40.42,105.91V109.49C40.42,109.51 40.43,109.57 40.49,109.63C40.55,109.69 40.65,109.73 40.77,109.73H44.55C45.66,109.73 46.55,110.63 46.55,111.73C46.55,112.84 45.66,113.73 44.55,113.73H40.77C38.46,113.73 36.42,111.91 36.42,109.49V105.91C36.42,104.8 37.31,103.91 38.42,103.91ZM85.6,103.91C86.7,103.91 87.6,104.8 87.6,105.91V109.49C87.6,111.9 85.57,113.73 83.24,113.73H79.46C78.35,113.73 77.46,112.84 77.46,111.73C77.46,110.63 78.35,109.73 79.46,109.73H83.24C83.37,109.73 83.47,109.68 83.53,109.63C83.58,109.57 83.6,109.52 83.6,109.49V105.91C83.6,104.8 84.49,103.91 85.6,103.91Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M62.15,70.01C57.23,70.01 53.38,72.96 53.38,76.96V79.51C53.38,79.51 53.38,79.52 53.38,79.52C53.38,79.62 53.38,80.05 53.18,80.51C53.08,80.75 52.95,80.93 52.87,81.02C52.82,81.09 52.77,81.15 52.74,81.18C52.71,81.21 52.68,81.25 52.65,81.28C52.64,81.3 52.62,81.32 52.61,81.33C52.55,81.4 52.48,81.48 52.4,81.58C52.37,81.62 52.36,81.64 52.35,81.66C52.34,81.67 52.33,81.7 52.33,81.76C52.33,81.78 52.32,81.8 52.32,81.82C52.31,81.94 52.32,82.21 52.53,82.79C53.3,84.95 54.78,87.61 56.59,89.73C58.44,91.88 60.41,93.21 62.15,93.21C63,93.21 63.97,92.89 64.97,92.26L64.97,92.25C65.98,91.62 66.98,90.72 67.93,89.64C69.69,87.61 71.03,85.14 71.52,83.17L71.09,80.86C71.05,80.79 71.01,80.73 70.98,80.66C70.88,80.46 70.81,80.25 70.78,80.03C70.78,80.02 70.78,80 70.77,79.98C70.76,79.93 70.76,79.87 70.75,79.8C70.75,79.73 70.74,79.66 70.74,79.58V78.87C70.74,78.77 70.75,78.68 70.76,78.59L70.7,76.76C70.43,74.49 69.48,72.84 68.07,71.74C66.63,70.62 64.62,70.01 62.15,70.01ZM70.77,79.78L70.77,79.78L70.77,79.78L70.77,79.79C70.77,79.79 70.77,79.78 70.77,79.78ZM75.47,83.81C75.53,83.64 75.56,83.46 75.57,83.28C75.69,82.52 75.73,81.77 75.62,81.07C75.53,80.45 75.34,79.94 75.1,79.53L74.69,76.38C74.32,73.13 72.9,70.43 70.53,68.59L70.53,68.59C68.2,66.78 65.24,66.01 62.15,66.01C56.06,66.01 49.38,69.84 49.38,76.96V78.96C49.35,78.99 49.32,79.02 49.3,79.05L49.3,79.06C48.85,79.61 48.43,80.39 48.34,81.42C48.24,82.39 48.47,83.31 48.76,84.13L48.76,84.13C49.69,86.75 51.42,89.84 53.55,92.33L53.55,92.33C55.56,94.67 58.56,97.21 62.15,97.21C63.98,97.21 65.68,96.54 67.1,95.64C68.53,94.75 69.82,93.54 70.94,92.28L70.94,92.27C73.08,89.81 74.86,86.65 75.47,83.81ZM49.38,79.52C49.38,79.52 49.38,79.52 49.38,79.52L49.38,79.52Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M56.68,91.31C57.92,91.17 59.05,92.07 59.18,93.32C59.29,94.28 59.33,95.8 59.14,97.39C58.96,98.93 58.52,100.84 57.44,102.34C56.71,103.36 55.29,103.59 54.28,102.86C53.26,102.13 53.02,100.71 53.76,99.69C54.16,99.12 54.48,98.12 54.63,96.85C54.78,95.64 54.74,94.46 54.67,93.81C54.53,92.57 55.43,91.44 56.68,91.31Z" />
<path
android:fillColor="#020F66"
android:fillType="evenOdd"
android:pathData="M68.12,91.31C69.37,91.44 70.27,92.57 70.13,93.81C70.06,94.46 70.02,95.64 70.17,96.85C70.32,98.12 70.64,99.12 71.04,99.69C71.78,100.71 71.54,102.13 70.52,102.86C69.51,103.59 68.09,103.36 67.36,102.34C66.28,100.84 65.84,98.93 65.66,97.39C65.47,95.8 65.51,94.28 65.62,93.32C65.75,92.07 66.88,91.17 68.12,91.31Z" />
<path
android:fillColor="#10949D"
android:fillType="evenOdd"
android:pathData="M104.11,118.55C104.11,110.43 110.72,103.86 118.83,103.86H152.25C160.39,103.86 166.98,110.45 166.98,118.55C166.98,126.68 160.37,133.25 152.25,133.25H118.83C110.7,133.25 104.11,126.66 104.11,118.55ZM118.83,107.86C112.91,107.86 108.11,112.65 108.11,118.55C108.11,124.44 112.9,129.25 118.83,129.25H152.25C158.18,129.25 162.98,124.46 162.98,118.55C162.98,112.66 158.19,107.86 152.25,107.86H118.83Z" />
<path
android:fillColor="#020F66"
android:pathData="M119.37,122.22C121.4,122.22 123.04,120.58 123.04,118.55C123.04,116.53 121.4,114.89 119.37,114.89C117.33,114.89 115.69,116.53 115.69,118.55C115.69,120.58 117.33,122.22 119.37,122.22Z" />
<path
android:fillColor="#020F66"
android:pathData="M129.66,122.22C131.7,122.22 133.34,120.58 133.34,118.55C133.34,116.53 131.7,114.89 129.66,114.89C127.63,114.89 125.99,116.53 125.99,118.55C125.99,120.58 127.63,122.22 129.66,122.22Z" />
<path
android:fillColor="#020F66"
android:pathData="M139.98,122.22C142.01,122.22 143.66,120.58 143.66,118.55C143.66,116.53 142.01,114.89 139.98,114.89C137.95,114.89 136.3,116.53 136.3,118.55C136.3,120.58 137.95,122.22 139.98,122.22Z" />
<path
android:fillColor="#020F66"
android:pathData="M150.27,122.22C152.3,122.22 153.95,120.58 153.95,118.55C153.95,116.53 152.3,114.89 150.27,114.89C148.24,114.89 146.59,116.53 146.59,118.55C146.59,120.58 148.24,122.22 150.27,122.22Z" />
</vector>

View file

@ -945,4 +945,11 @@ Do you want to switch to this account?</string>
<string name="totally_different_from_your_other_passwords">Totally different from your other passwords</string>
<string name="use_the_generator_to_create_a_strong_unique_password">Use the generator to create a strong, unique password</string>
<string name="try_it_out">Try it out</string>
<string name="account_setup">Account setup</string>
<string name="set_up_unlock">Set up unlock</string>
<string name="set_up_later">Set up later</string>
<string name="set_up_unlock_later">Set up unlock later?</string>
<string name="you_can_return_to_complete_this_step_anytime_from_account_security_in_settings">You can return to complete this step anytime from Account Security in Settings.</string>
<string name="confirm">Confirm</string>
<string name="set_up_biometrics_or_choose_a_pin_code_to_quickly_access_your_vault_and_autofill_your_logins">Set up biometrics or choose a PIN code to quickly access your vault and AutoFill your logins.</string>
</resources>

View file

@ -0,0 +1,478 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.filterToOne
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.components.toggle.UnlockWithPinState
import com.x8bit.bitwarden.ui.platform.manager.biometrics.BiometricsManager
import com.x8bit.bitwarden.ui.util.assertNoDialogExists
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.robolectric.annotation.Config
class SetupUnlockScreenTest : BaseComposeTest() {
private var onNavigateToSetupAutofillCalled = false
private val biometricsManager: BiometricsManager = mockk {
every { isBiometricsSupported } returns true
}
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val mutableEventFlow = bufferedMutableSharedFlow<SetupUnlockEvent>()
private val viewModel = mockk<SetupUnlockViewModel> {
every { stateFlow } returns mutableStateFlow
every { eventFlow } returns mutableEventFlow
every { trySendAction(action = any()) } just runs
}
@Before
fun setup() {
composeTestRule.setContent {
SetupUnlockScreen(
onNavigateToSetupAutofill = { onNavigateToSetupAutofillCalled = true },
viewModel = viewModel,
biometricsManager = biometricsManager,
)
}
}
@Config(qualifiers = "land")
@Test
fun `header should display in landscape mode`() {
composeTestRule
.onNodeWithText(text = "Set up unlock")
.performScrollTo()
.assertExists()
.assertIsDisplayed()
@Suppress("MaxLineLength")
composeTestRule
.onNodeWithText(
text = "Set up biometrics or choose a PIN code to quickly access your vault and AutoFill your logins.",
)
.performScrollTo()
.assertExists()
.assertIsDisplayed()
}
@Test
fun `NavigateToSetupAutofill event should invoke the navigate to autofill lambda`() {
mutableEventFlow.tryEmit(SetupUnlockEvent.NavigateToSetupAutofill)
assertTrue(onNavigateToSetupAutofillCalled)
}
@Test
fun `on unlock with biometrics should be toggled on or off according to state`() {
composeTestRule.onNodeWithText(text = "Unlock with Biometrics").assertIsOff()
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = true) }
composeTestRule.onNodeWithText(text = "Unlock with Biometrics").assertIsOn()
}
@Suppress("MaxLineLength")
@Test
fun `on unlock with biometrics toggle should send EnableBiometricsClick when isUnlockWithBiometricsEnabled is false`() {
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOff()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SetupUnlockAction.EnableBiometricsClick)
}
}
@Test
fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle`() {
mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = true) }
composeTestRule
.onNodeWithText(text = "Unlock with Biometrics")
.performScrollTo()
.assertIsOn()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SetupUnlockAction.UnlockWithBiometricToggle(isEnabled = false))
}
}
@Test
fun `on unlock with pin code should be toggled on or off according to state`() {
composeTestRule.onNodeWithText(text = "Unlock with PIN code").assertIsOff()
mutableStateFlow.update { it.copy(isUnlockWithPinEnabled = true) }
composeTestRule.onNodeWithText(text = "Unlock with PIN code").assertIsOn()
}
@Test
fun `on unlock with pin toggle when enabled should send UnlockWithPinToggle Disabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = true)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `on unlock with pin toggle when disabled should show the PIN input dialog and send UnlockWithPinToggle PendingEnabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Enter your PIN code.")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "Set your PIN code for unlocking Bitwarden. Your PIN settings will be reset if " +
"you ever fully log out of the application.",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(UnlockWithPinState.PendingEnabled),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `PIN input dialog Cancel click should clear the dialog and send UnlockWithPinToggle Disabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `PIN input dialog Submit click with empty pin should clear the dialog and send UnlockWithPinToggle Disabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(UnlockWithPinState.Disabled),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `PIN input dialog Submit click with non-empty pin and isUnlockWithPasswordEnabled true should show a confirmation dialog and send UnlockWithPinToggle PendingEnabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput(text = "1234")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Unlock with PIN code")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(
text = "Do you want to require unlocking with your master password when the application " +
"is restarted?",
)
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText(text = "No")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(UnlockWithPinState.PendingEnabled),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `PIN input dialog Submit click with non-empty pin and isUnlockWithPasswordEnabled false should show a confirmation dialog and send UnlockWithPinToggle Enabled`() {
mutableStateFlow.update {
it.copy(
isUnlockWithPinEnabled = false,
isUnlockWithPasswordEnabled = false,
)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput(text = "1234")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
),
),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `PIN confirmation dialog No click should send UnlockWithPinToggle Enabled and close the dialog`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput(text = "1234")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "No")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = false,
),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `PIN confirmation dialog Yes click should send UnlockWithPinToggle Enabled and close the dialog`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = false)
}
composeTestRule
.onNodeWithText(text = "Unlock with PIN code")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "PIN")
.filterToOne(hasAnyAncestor(isDialog()))
.performTextInput(text = "1234")
composeTestRule
.onAllNodesWithText(text = "Submit")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule
.onAllNodesWithText(text = "Yes")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
verify {
viewModel.trySendAction(
SetupUnlockAction.UnlockWithPinToggle(
UnlockWithPinState.Enabled(
pin = "1234",
shouldRequireMasterPasswordOnRestart = true,
),
),
)
}
composeTestRule.assertNoDialogExists()
}
@Test
fun `on Continue click should send ContinueClick when disabled`() {
composeTestRule
.onNodeWithText(text = "Continue")
.performScrollTo()
.assertIsDisplayed()
.performClick()
verify(exactly = 0) {
viewModel.trySendAction(SetupUnlockAction.ContinueClick)
}
}
@Test
fun `on Continue click should send ContinueClick when enabled`() {
mutableStateFlow.update {
it.copy(isUnlockWithPinEnabled = true)
}
composeTestRule
.onNodeWithText(text = "Continue")
.performScrollTo()
.assertIsEnabled()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SetupUnlockAction.ContinueClick)
}
}
@Test
fun `on Set up later click should display confirmation dialog`() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Set up later")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Set up unlock later?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
}
@Test
fun `on Set up later dialog cancel click should dismiss the dialog`() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Set up later")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
@Suppress("MaxLineLength")
@Test
fun `on Set up later dialog confirm click should dismiss the dialog and send SetUpLaterClick`() {
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithText(text = "Set up later")
.performScrollTo()
.performClick()
composeTestRule
.onAllNodesWithText(text = "Confirm")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify(exactly = 1) {
viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick)
}
}
}
private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
isUnlockWithPinEnabled = false,
isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false,
)

View file

@ -0,0 +1,98 @@
package com.x8bit.bitwarden.ui.auth.feature.accountsetup
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.manager.BiometricsEncryptionManager
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import javax.crypto.Cipher
class SetupUnlockViewModelTest : BaseViewModelTest() {
private val mutableUserStateFlow = MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
private val authRepository: AuthRepository = mockk {
every { userStateFlow } returns mutableUserStateFlow
}
private val settingsRepository = mockk<SettingsRepository> {
every { isUnlockWithPinEnabled } returns false
every { isUnlockWithBiometricsEnabled } returns false
}
private val biometricsEncryptionManager: BiometricsEncryptionManager = mockk {
every { getOrCreateCipher(userId = DEFAULT_USER_ID) } returns CIPHER
every {
isBiometricIntegrityValid(userId = DEFAULT_USER_ID, cipher = CIPHER)
} returns false
}
@Test
fun `initial state should be correct`() {
val viewModel = createViewModel()
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
}
@Test
fun `ContinueClick should emit NavigateToSetupAutofill`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SetupUnlockAction.ContinueClick)
assertEquals(SetupUnlockEvent.NavigateToSetupAutofill, awaitItem())
}
}
@Test
fun `SetUpLaterClick should emit NavigateToSetupAutofill`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SetupUnlockAction.SetUpLaterClick)
assertEquals(SetupUnlockEvent.NavigateToSetupAutofill, awaitItem())
}
}
private fun createViewModel(
state: SetupUnlockState? = null,
): SetupUnlockViewModel =
SetupUnlockViewModel(
savedStateHandle = SavedStateHandle(mapOf("state" to state)),
authRepository = authRepository,
settingsRepository = settingsRepository,
biometricsEncryptionManager = biometricsEncryptionManager,
)
}
private val DEFAULT_STATE: SetupUnlockState = SetupUnlockState(
isUnlockWithPinEnabled = false,
isUnlockWithPasswordEnabled = true,
isUnlockWithBiometricsEnabled = false,
)
private val CIPHER = mockk<Cipher>()
private const val DEFAULT_USER_ID: String = "activeUserId"
private val DEFAULT_USER_STATE: UserState = UserState(
activeUserId = DEFAULT_USER_ID,
accounts = listOf(
UserState.Account(
userId = DEFAULT_USER_ID,
name = "Active User",
email = "active@bitwarden.com",
avatarColorHex = "#aa00aa",
environment = Environment.Us,
isPremium = true,
isLoggedIn = true,
isVaultUnlocked = true,
needsPasswordReset = false,
isBiometricsEnabled = false,
organizations = emptyList(),
needsMasterPassword = false,
trustedDevice = null,
),
),
)