From de9e32f6aa4d696803615ee3482920fa5da4e2b5 Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Mon, 8 Jan 2024 09:19:55 -0600 Subject: [PATCH] BIT-363, BIT-1323: Add time interval options to session timeout menu (#529) --- .../datasource/disk/di/PlatformDiskModule.kt | 11 + .../repository/di/PlatformRepositoryModule.kt | 16 ++ .../accountsecurity/AccountSecurityScreen.kt | 90 +++++++- .../AccountSecurityViewModel.kt | 50 ++-- .../platform/util/VaultTimeoutExtensions.kt | 23 ++ .../AccountSecurityScreenTest.kt | 218 ++++++++++++++++-- .../AccountSecurityViewModelTest.kt | 42 ++-- .../util/VaultTimeoutExtensionsTest.kt | 30 +++ 8 files changed, 428 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index ca1b9c3314..4c38bd1b64 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.platform.datasource.disk.di import android.content.SharedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,4 +29,13 @@ object PlatformDiskModule { sharedPreferences = sharedPreferences, json = json, ) + + @Provides + @Singleton + fun provideSettingsDiskSource( + sharedPreferences: SharedPreferences, + ): SettingsDiskSource = + SettingsDiskSourceImpl( + sharedPreferences = sharedPreferences, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt index 5c56f5c87f..321918e87b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/di/PlatformRepositoryModule.kt @@ -2,9 +2,12 @@ package com.x8bit.bitwarden.data.platform.repository.di import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepositoryImpl +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.SettingsRepositoryImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -30,4 +33,17 @@ object PlatformRepositoryModule { authDiskSource = authDiskSource, dispatcherManager = dispatcherManager, ) + + @Provides + @Singleton + fun provideSettingsRepository( + authDiskSource: AuthDiskSource, + settingsDiskSource: SettingsDiskSource, + dispatcherManager: DispatcherManager, + ): SettingsRepository = + SettingsRepositoryImpl( + authDiskSource = authDiskSource, + settingsDiskSource = settingsDiskSource, + dispatcherManager = dispatcherManager, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt index 30544eb690..b947badbf1 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreen.kt @@ -18,7 +18,9 @@ import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -28,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -40,9 +43,11 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton import com.x8bit.bitwarden.ui.platform.components.BitwardenTextRow import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar +import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialColors import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography +import com.x8bit.bitwarden.ui.platform.util.displayLabel /** * Displays the account security screen. @@ -198,19 +203,13 @@ fun AccountSecurityScreen( .fillMaxWidth() .padding(horizontal = 16.dp), ) - BitwardenTextRow( - text = stringResource(id = R.string.session_timeout), - onClick = remember(viewModel) { - { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutClick) } + SessionTimeoutRow( + selectedVaultTimeoutType = state.vaultTimeoutType, + onVaultTimeoutTypeSelect = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.VaultTimeoutTypeSelect(it)) } }, modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = state.sessionTimeout(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ) BitwardenTextRow( text = stringResource(id = R.string.session_timeout_action), onClick = remember(viewModel) { @@ -286,6 +285,70 @@ fun AccountSecurityScreen( } } +@Suppress("LongMethod") +@Composable +private fun SessionTimeoutRow( + selectedVaultTimeoutType: VaultTimeout.Type, + onVaultTimeoutTypeSelect: (VaultTimeout.Type) -> Unit, + modifier: Modifier = Modifier, +) { + var shouldShowSelectionDialog by remember { mutableStateOf(false) } + var shouldShowNeverTimeoutConfirmationDialog by remember { mutableStateOf(false) } + BitwardenTextRow( + text = stringResource(id = R.string.session_timeout), + onClick = { shouldShowSelectionDialog = true }, + modifier = modifier, + ) { + Text( + text = selectedVaultTimeoutType.displayLabel(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + when { + shouldShowSelectionDialog -> { + val vaultTimeoutOptions = VaultTimeout.Type.entries + BitwardenSelectionDialog( + title = stringResource(id = R.string.session_timeout), + onDismissRequest = { shouldShowSelectionDialog = false }, + ) { + vaultTimeoutOptions.forEach { vaultTimeoutOption -> + BitwardenSelectionRow( + text = vaultTimeoutOption.displayLabel, + onClick = { + shouldShowSelectionDialog = false + val selectedType = + vaultTimeoutOptions.first { it == vaultTimeoutOption } + if (selectedType == VaultTimeout.Type.NEVER) { + shouldShowNeverTimeoutConfirmationDialog = true + } else { + onVaultTimeoutTypeSelect(selectedType) + } + }, + isSelected = selectedVaultTimeoutType == vaultTimeoutOption, + ) + } + } + } + + shouldShowNeverTimeoutConfirmationDialog -> { + BitwardenTwoButtonDialog( + title = stringResource(id = R.string.warning), + message = stringResource(id = R.string.never_lock_warning), + confirmButtonText = stringResource(id = R.string.ok), + dismissButtonText = stringResource(id = R.string.cancel), + onConfirmClick = { + shouldShowNeverTimeoutConfirmationDialog = false + onVaultTimeoutTypeSelect(VaultTimeout.Type.NEVER) + }, + onDismissClick = { shouldShowNeverTimeoutConfirmationDialog = false }, + onDismissRequest = { shouldShowNeverTimeoutConfirmationDialog = false }, + ) + } + } +} + @Composable private fun FingerPrintPhraseDialog( fingerprintPhrase: Text, @@ -349,7 +412,10 @@ private fun SessionTimeoutActionDialog( BitwardenSelectionRow( text = option.text, isSelected = option == selectedSessionTimeoutAction, - onClick = { onActionSelect(SessionTimeoutAction.values().first { it == option }) }, + onClick = { + onActionSelect( + SessionTimeoutAction.values().first { it == option }) + }, ) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt index b300f597ac..1b2010c584 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text @@ -26,6 +28,7 @@ private const val KEY_STATE = "state" class AccountSecurityViewModel @Inject constructor( private val authRepository: AuthRepository, private val vaultRepository: VaultRepository, + private val settingsRepository: SettingsRepository, savedStateHandle: SavedStateHandle, ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] @@ -35,7 +38,7 @@ class AccountSecurityViewModel @Inject constructor( isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, - sessionTimeout = "15 Minutes".asText(), + vaultTimeoutType = settingsRepository.vaultTimeout.type, sessionTimeoutAction = SessionTimeoutAction.LOCK, ), ) { @@ -58,12 +61,12 @@ class AccountSecurityViewModel @Inject constructor( is AccountSecurityAction.LoginRequestToggle -> handleLoginRequestToggle(action) AccountSecurityAction.LogoutClick -> handleLogoutClick() AccountSecurityAction.PendingLoginRequestsClick -> handlePendingLoginRequestsClick() + is AccountSecurityAction.VaultTimeoutTypeSelect -> handleVaultTimeoutTypeSelect(action) is AccountSecurityAction.SessionTimeoutActionSelect -> { handleSessionTimeoutActionSelect(action) } AccountSecurityAction.SessionTimeoutActionClick -> handleSessionTimeoutActionClick() - AccountSecurityAction.SessionTimeoutClick -> handleSessionTimeoutClick() AccountSecurityAction.TwoStepLoginClick -> handleTwoStepLoginClick() is AccountSecurityAction.UnlockWithBiometricToggle -> { handleUnlockWithBiometricToggled(action) @@ -119,6 +122,30 @@ class AccountSecurityViewModel @Inject constructor( sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) } + private fun handleVaultTimeoutTypeSelect(action: AccountSecurityAction.VaultTimeoutTypeSelect) { + val vaultTimeoutType = action.vaultTimeoutType + mutableStateFlow.update { + it.copy( + vaultTimeoutType = action.vaultTimeoutType, + ) + } + val vaultTimeout = when (vaultTimeoutType) { + VaultTimeout.Type.IMMEDIATELY -> VaultTimeout.Immediately + VaultTimeout.Type.ONE_MINUTE -> VaultTimeout.OneMinute + VaultTimeout.Type.FIVE_MINUTES -> VaultTimeout.FiveMinutes + VaultTimeout.Type.THIRTY_MINUTES -> VaultTimeout.ThirtyMinutes + VaultTimeout.Type.ONE_HOUR -> VaultTimeout.OneHour + VaultTimeout.Type.FOUR_HOURS -> VaultTimeout.FourHours + VaultTimeout.Type.ON_APP_RESTART -> VaultTimeout.OnAppRestart + VaultTimeout.Type.NEVER -> VaultTimeout.Never + VaultTimeout.Type.CUSTOM -> VaultTimeout.Custom(vaultTimeoutInMinutes = 0) + } + settingsRepository.vaultTimeout = vaultTimeout + + // TODO: Finish implementing vault timeouts (BIT-1120) + sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + } + private fun handleSessionTimeoutActionSelect( action: AccountSecurityAction.SessionTimeoutActionSelect, ) { @@ -136,11 +163,6 @@ class AccountSecurityViewModel @Inject constructor( mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.SessionTimeoutAction) } } - private fun handleSessionTimeoutClick() { - // TODO BIT-462: Implement session timeout - sendEvent(AccountSecurityEvent.ShowToast("Display session timeout dialog.".asText())) - } - private fun handleTwoStepLoginClick() { // TODO BIT-468: Implement two-step login sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) @@ -171,7 +193,7 @@ data class AccountSecurityState( val isApproveLoginRequestsEnabled: Boolean, val isUnlockWithBiometricsEnabled: Boolean, val isUnlockWithPinEnabled: Boolean, - val sessionTimeout: Text, + val vaultTimeoutType: VaultTimeout.Type, val sessionTimeoutAction: SessionTimeoutAction, ) : Parcelable @@ -295,6 +317,13 @@ sealed class AccountSecurityAction { */ data object PendingLoginRequestsClick : AccountSecurityAction() + /** + * User selected a [vaultTimeoutType]. + */ + data class VaultTimeoutTypeSelect( + val vaultTimeoutType: VaultTimeout.Type, + ) : AccountSecurityAction() + /** * User selected a [SessionTimeoutAction]. */ @@ -307,11 +336,6 @@ sealed class AccountSecurityAction { */ data object SessionTimeoutActionClick : AccountSecurityAction() - /** - * User clicked session timeout. - */ - data object SessionTimeoutClick : AccountSecurityAction() - /** * User clicked two-step login. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt new file mode 100644 index 0000000000..10c1eeabf5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensions.kt @@ -0,0 +1,23 @@ +package com.x8bit.bitwarden.ui.platform.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * Provides a human-readable display label for the given [VaultTimeout.Type]. + */ +val VaultTimeout.Type.displayLabel: Text + get() = when (this) { + VaultTimeout.Type.IMMEDIATELY -> R.string.immediately + VaultTimeout.Type.ONE_MINUTE -> R.string.one_minute + VaultTimeout.Type.FIVE_MINUTES -> R.string.five_minutes + VaultTimeout.Type.THIRTY_MINUTES -> R.string.thirty_minutes + VaultTimeout.Type.ONE_HOUR -> R.string.one_hour + VaultTimeout.Type.FOUR_HOURS -> R.string.four_hours + VaultTimeout.Type.ON_APP_RESTART -> R.string.on_restart + VaultTimeout.Type.NEVER -> R.string.never + VaultTimeout.Type.CUSTOM -> R.string.custom + } + .asText() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt index 6015840270..7aadecfe9f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityScreenTest.kt @@ -15,10 +15,12 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.core.net.toUri +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.util.assertNoDialogExists import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -124,29 +126,217 @@ class AccountSecurityScreenTest : BaseComposeTest() { composeTestRule.onNodeWithText("Unlock with PIN code").assertIsOn() } - @Test - fun `on session timeout click should send SessionTimeoutClick`() { - composeTestRule - .onAllNodesWithText("Session timeout") - .filterToOne(hasClickAction()) - .performScrollTo() - .performClick() - verify { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutClick) } - } - @Test fun `session timeout should be updated on or off according to state`() { composeTestRule .onAllNodesWithText("Session timeout") .filterToOne(hasClickAction()) .performScrollTo() - .assertTextEquals("Session timeout", "15 Minutes") - mutableStateFlow.update { it.copy(sessionTimeout = "30 Minutes".asText()) } + .assertTextEquals("Session timeout", "30 minutes") + mutableStateFlow.update { it.copy(vaultTimeoutType = VaultTimeout.Type.FOUR_HOURS) } composeTestRule .onAllNodesWithText("Session timeout") .filterToOne(hasClickAction()) .performScrollTo() - .assertTextEquals("Session timeout", "30 Minutes") + .assertTextEquals("Session timeout", "4 hours") + } + + @Test + fun `on session timeout click should show a selection dialog`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Immediately") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("1 minute") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("5 minutes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("30 minutes") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("1 hour") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("4 hours") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("On app restart") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Never") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Custom") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on session timeout selection dialog cancel click should close the dialog`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on session timeout selection non-Never timeout type click should send VaultTimeoutTypeSelect and close the dialog`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("4 hours") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + AccountSecurityAction.VaultTimeoutTypeSelect(VaultTimeout.Type.FOUR_HOURS), + ) + } + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on session timeout selection Never timeout type click should show a confirmation dialog`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Never") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Warning") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText( + "Setting your lock options to “Never” keeps your vault available to anyone with " + + "access to your device. If you use this option, you should ensure that you " + + "keep your device properly protected.", + ) + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `on session timeout Never confirmation dialog Cancel click should close the dialog`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Never") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Warning") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Cancel") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify(exactly = 0) { viewModel.trySendAction(any()) } + composeTestRule.assertNoDialogExists() + } + + @Suppress("MaxLineLength") + @Test + fun `on session timeout Never confirmation dialog Ok click should close the dialog and emit VaultTimeoutTypeSelect`() { + composeTestRule.assertNoDialogExists() + + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Never") + .filterToOne(hasAnyAncestor(isDialog())) + .performScrollTo() + .performClick() + + composeTestRule + .onAllNodesWithText("Warning") + .filterToOne(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + + verify { + viewModel.trySendAction( + AccountSecurityAction.VaultTimeoutTypeSelect(VaultTimeout.Type.NEVER), + ) + } + composeTestRule.assertNoDialogExists() } @Test @@ -337,7 +527,7 @@ class AccountSecurityScreenTest : BaseComposeTest() { isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, - sessionTimeout = "15 Minutes".asText(), + vaultTimeoutType = VaultTimeout.Type.THIRTY_MINUTES, sessionTimeoutAction = SessionTimeoutAction.LOCK, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt index 9e6c4336fb..eceb0de93e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/accountsecurity/AccountSecurityViewModelTest.kt @@ -3,6 +3,8 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.platform.repository.SettingsRepository +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.util.asText @@ -119,6 +121,30 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } } + @Test + fun `on VaultTimeoutTypeSelect should update the selection and emit ShowToast()`() = runTest { + val settingsRepository = mockk() { + every { vaultTimeout = any() } just runs + } + val viewModel = createViewModel(settingsRepository = settingsRepository) + viewModel.eventFlow.test { + viewModel.trySendAction( + AccountSecurityAction.VaultTimeoutTypeSelect(VaultTimeout.Type.FOUR_HOURS), + ) + assertEquals( + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + assertEquals( + DEFAULT_STATE.copy( + vaultTimeoutType = VaultTimeout.Type.FOUR_HOURS, + ), + viewModel.stateFlow.value, + ) + verify { settingsRepository.vaultTimeout = VaultTimeout.FourHours } + } + @Test fun `on SessionTimeoutActionSelect should update session timeout action`() = runTest { val viewModel = createViewModel() @@ -148,18 +174,6 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { ) } - @Test - fun `on SessionTimeoutClick should emit ShowToast`() = runTest { - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.trySendAction(AccountSecurityAction.SessionTimeoutClick) - assertEquals( - AccountSecurityEvent.ShowToast("Display session timeout dialog.".asText()), - awaitItem(), - ) - } - } - @Test fun `on TwoStepLoginClick should emit NavigateToTwoStepLogin`() = runTest { val viewModel = createViewModel() @@ -235,12 +249,14 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { private fun createViewModel( authRepository: AuthRepository = mockk(relaxed = true), vaultRepository: VaultRepository = mockk(relaxed = true), + settingsRepository: SettingsRepository = mockk(relaxed = true), savedStateHandle: SavedStateHandle = SavedStateHandle().apply { set("state", DEFAULT_STATE) }, ): AccountSecurityViewModel = AccountSecurityViewModel( authRepository = authRepository, vaultRepository = vaultRepository, + settingsRepository = settingsRepository, savedStateHandle = savedStateHandle, ) @@ -251,7 +267,7 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { isApproveLoginRequestsEnabled = false, isUnlockWithBiometricsEnabled = false, isUnlockWithPinEnabled = false, - sessionTimeout = "15 Minutes".asText(), + vaultTimeoutType = VaultTimeout.Type.THIRTY_MINUTES, sessionTimeoutAction = SessionTimeoutAction.LOCK, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt new file mode 100644 index 0000000000..cdc2bb8863 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/VaultTimeoutExtensionsTest.kt @@ -0,0 +1,30 @@ +package com.x8bit.bitwarden.ui.platform.util + +import com.x8bit.bitwarden.R +import com.x8bit.bitwarden.data.platform.repository.model.VaultTimeout +import com.x8bit.bitwarden.ui.platform.base.util.asText +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VaultTimeoutExtensionsTest { + @Test + fun `displayLabel should return the correct value for each type`() { + mapOf( + VaultTimeout.Type.IMMEDIATELY to R.string.immediately.asText(), + VaultTimeout.Type.ONE_MINUTE to R.string.one_minute.asText(), + VaultTimeout.Type.FIVE_MINUTES to R.string.five_minutes.asText(), + VaultTimeout.Type.THIRTY_MINUTES to R.string.thirty_minutes.asText(), + VaultTimeout.Type.ONE_HOUR to R.string.one_hour.asText(), + VaultTimeout.Type.FOUR_HOURS to R.string.four_hours.asText(), + VaultTimeout.Type.ON_APP_RESTART to R.string.on_restart.asText(), + VaultTimeout.Type.NEVER to R.string.never.asText(), + VaultTimeout.Type.CUSTOM to R.string.custom.asText(), + ) + .forEach { (type, label) -> + assertEquals( + label, + type.displayLabel, + ) + } + } +}