diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockNavigation.kt new file mode 100644 index 000000000..51a9ed3d9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockNavigation.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreen.kt new file mode 100644 index 000000000..c3bbba7ee --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreen.kt @@ -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(), + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModel.kt new file mode 100644 index 000000000..6efb6f790 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModel.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/handlers/SetupUnlockHandler.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/handlers/SetupUnlockHandler.kt new file mode 100644 index 000000000..601f53799 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/handlers/SetupUnlockHandler.kt @@ -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) }, + ) + } +} diff --git a/app/src/main/res/drawable-night/account_setup.xml b/app/src/main/res/drawable-night/account_setup.xml new file mode 100644 index 000000000..59ba45bac --- /dev/null +++ b/app/src/main/res/drawable-night/account_setup.xml @@ -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> diff --git a/app/src/main/res/drawable/account_setup.xml b/app/src/main/res/drawable/account_setup.xml new file mode 100644 index 000000000..6804a7cc3 --- /dev/null +++ b/app/src/main/res/drawable/account_setup.xml @@ -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> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89365a782..c8e31861e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -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> diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreenTest.kt new file mode 100644 index 000000000..f327be8ba --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockScreenTest.kt @@ -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, +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModelTest.kt new file mode 100644 index 000000000..a9dfd10b3 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/accountsetup/SetupUnlockViewModelTest.kt @@ -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, + ), + ), +)