From 3d28cd11002c00a7baa6df2a38ea306a38812769 Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 1 Nov 2023 20:06:22 -0500 Subject: [PATCH] BIT-926: account security UI (#193) --- .../accountsecurity/AccountSecurityScreen.kt | 235 +++++++++++++-- .../AccountSecurityViewModel.kt | 221 +++++++++++++- .../AccountSecurityScreenTest.kt | 276 +++++++++++++----- .../AccountSecurityViewModelTest.kt | 234 ++++++++++++--- 4 files changed, 803 insertions(+), 163 deletions(-) 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 10c1f96f2..7e2c7540c 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 @@ -1,8 +1,7 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity +import android.widget.Toast import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -11,7 +10,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -24,49 +22,69 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect -import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BitwardenExternalLinkRow +import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText +import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionDialog +import com.x8bit.bitwarden.ui.platform.components.BitwardenSelectionRow +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 /** * Displays the account security screen. */ +@Suppress("LongMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSecurityScreen( onNavigateBack: () -> Unit, viewModel: AccountSecurityViewModel = hiltViewModel(), + intentHandler: IntentHandler = IntentHandler(context = LocalContext.current), ) { val state by viewModel.stateFlow.collectAsState() + val context = LocalContext.current + val resources = context.resources EventsEffect(viewModel = viewModel) { event -> when (event) { - AccountSecurityEvent.NavigateBack -> onNavigateBack.invoke() + AccountSecurityEvent.NavigateBack -> onNavigateBack() + + is AccountSecurityEvent.ShowToast -> { + Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show() + } } } - if (state.shouldShowConfirmLogoutDialog) { - BitwardenTwoButtonDialog( - title = R.string.log_out.asText(), - message = R.string.logout_confirmation.asText(), - confirmButtonText = R.string.yes.asText(), + when (state.dialog) { + AccountSecurityDialog.ConfirmLogout -> ConfirmLogoutDialog( + onDismiss = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } + }, onConfirmClick = remember(viewModel) { { viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick) } }, - dismissButtonText = R.string.cancel.asText(), - onDismissClick = remember(viewModel) { - { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } - }, + ) + + AccountSecurityDialog.SessionTimeoutAction -> SessionTimeoutActionDialog( + selectedSessionTimeoutAction = state.sessionTimeoutAction, onDismissRequest = remember(viewModel) { { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } }, + onActionSelect = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionSelect(it)) } + }, ) + + null -> Unit } val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) Scaffold( @@ -92,33 +110,188 @@ fun AccountSecurityScreen( .background(color = MaterialTheme.colorScheme.surface) .verticalScroll(rememberScrollState()), ) { - Spacer(Modifier.height(8.dp)) - AccountSecurityRow( + BitwardenListHeaderText( + label = stringResource(id = R.string.approve_login_requests), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource( + id = R.string.use_this_device_to_approve_login_requests_made_from_other_devices, + ), + isChecked = state.isApproveLoginRequestsEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.LoginRequestToggle(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenTextRow( + text = R.string.pending_log_in_requests.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.unlock_options), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource( + id = R.string.unlock_with, + stringResource(id = R.string.biometrics), + ), + isChecked = state.isUnlockWithBiometricsEnabled, + onCheckedChange = remember(viewModel) { + { + viewModel.trySendAction( + AccountSecurityAction.UnlockWithBiometricToggle(it), + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenWideSwitch( + label = stringResource(id = R.string.unlock_with_pin), + isChecked = state.isUnlockWithPinEnabled, + onCheckedChange = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(it)) } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.session_timeout), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenTextRow( + text = R.string.session_timeout.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = state.sessionTimeout(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + BitwardenTextRow( + text = R.string.session_timeout_action.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = state.sessionTimeoutAction.text(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.other), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + BitwardenTextRow( + text = R.string.account_fingerprint_phrase.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenExternalLinkRow( + text = R.string.two_step_login.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.TwoStepLoginClick) } + }, + withDivider = false, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenExternalLinkRow( + text = R.string.change_master_password.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.ChangeMasterPasswordClick) } + }, + withDivider = false, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenTextRow( + text = R.string.lock_now.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.LockNowClick) } + }, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenTextRow( text = R.string.log_out.asText(), onClick = remember(viewModel) { { viewModel.trySendAction(AccountSecurityAction.LogoutClick) } }, + modifier = Modifier.fillMaxWidth(), + ) + BitwardenTextRow( + text = R.string.delete_account.asText(), + onClick = remember(viewModel) { + { viewModel.trySendAction(AccountSecurityAction.DeleteAccountClick) } + }, + modifier = Modifier.fillMaxWidth(), ) } } } @Composable -private fun AccountSecurityRow( - text: Text, - onClick: () -> Unit, +private fun ConfirmLogoutDialog( + onDismiss: () -> Unit, + onConfirmClick: () -> Unit, ) { - Text( - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(color = MaterialTheme.colorScheme.primary), - onClick = onClick, - ) - .padding(horizontal = 16.dp, vertical = 16.dp) - .fillMaxWidth(), - text = text(), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + BitwardenTwoButtonDialog( + title = R.string.log_out.asText(), + message = R.string.logout_confirmation.asText(), + confirmButtonText = R.string.yes.asText(), + onConfirmClick = onConfirmClick, + dismissButtonText = R.string.cancel.asText(), + onDismissClick = onDismiss, + onDismissRequest = onDismiss, ) } + +@Composable +private fun SessionTimeoutActionDialog( + selectedSessionTimeoutAction: SessionTimeoutAction, + onDismissRequest: () -> Unit, + onActionSelect: (SessionTimeoutAction) -> Unit, +) { + BitwardenSelectionDialog( + title = R.string.vault_timeout_action.asText(), + onDismissRequest = onDismissRequest, + ) { + SessionTimeoutAction.values().forEach { option -> + BitwardenSelectionRow( + text = option.text, + isSelected = option == selectedSessionTimeoutAction, + 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 b827f6d86..6be713b1c 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 @@ -3,8 +3,11 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import android.os.Parcelable 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.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -17,6 +20,7 @@ private const val KEY_STATE = "state" /** * View model for the account security screen. */ +@Suppress("TooManyFunctions") @HiltViewModel class AccountSecurityViewModel @Inject constructor( private val authRepository: AuthRepository, @@ -24,7 +28,12 @@ class AccountSecurityViewModel @Inject constructor( ) : BaseViewModel( initialState = savedStateHandle[KEY_STATE] ?: AccountSecurityState( - shouldShowConfirmLogoutDialog = false, + dialog = null, + isApproveLoginRequestsEnabled = false, + isUnlockWithBiometricsEnabled = false, + isUnlockWithPinEnabled = false, + sessionTimeout = "15 Minutes".asText(), + sessionTimeoutAction = SessionTimeoutAction.LOCK, ), ) { @@ -35,25 +44,115 @@ class AccountSecurityViewModel @Inject constructor( } override fun handleAction(action: AccountSecurityAction): Unit = when (action) { - AccountSecurityAction.LogoutClick -> handleLogoutClick() + AccountSecurityAction.AccountFingerprintPhraseClick -> handleAccountFingerprintPhraseClick() AccountSecurityAction.BackClick -> handleBackClick() + AccountSecurityAction.ChangeMasterPasswordClick -> handleChangeMasterPasswordClick() AccountSecurityAction.ConfirmLogoutClick -> handleConfirmLogoutClick() + AccountSecurityAction.DeleteAccountClick -> handleDeleteAccountClick() AccountSecurityAction.DismissDialog -> handleDismissDialog() + AccountSecurityAction.LockNowClick -> handleLockNowClick() + is AccountSecurityAction.LoginRequestToggle -> handleLoginRequestToggle(action) + AccountSecurityAction.LogoutClick -> handleLogoutClick() + AccountSecurityAction.PendingLoginRequestsClick -> handlePendingLoginRequestsClick() + is AccountSecurityAction.SessionTimeoutActionSelect -> { + handleSessionTimeoutActionSelect(action) + } + + AccountSecurityAction.SessionTimeoutActionClick -> handleSessionTimeoutActionClick() + AccountSecurityAction.SessionTimeoutClick -> handleSessionTimeoutClick() + AccountSecurityAction.TwoStepLoginClick -> handleTwoStepLoginClick() + is AccountSecurityAction.UnlockWithBiometricToggle -> { + handleUnlockWithBiometricToggled(action) + } + + is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action) } - private fun handleLogoutClick() { - mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) } + private fun handleAccountFingerprintPhraseClick() { + // TODO BIT-470: Display fingerprint phrase + sendEvent(AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText())) } private fun handleBackClick() = sendEvent(AccountSecurityEvent.NavigateBack) + private fun handleChangeMasterPasswordClick() { + // TODO BIT-971: Add Leaving app Dialog + sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + } + private fun handleConfirmLogoutClick() { - mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) } + mutableStateFlow.update { it.copy(dialog = null) } authRepository.logout() } + private fun handleDeleteAccountClick() { + // TODO BIT-1031: Navigate to delete account + sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + } + private fun handleDismissDialog() { - mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = false) } + mutableStateFlow.update { it.copy(dialog = null) } + } + + private fun handleLockNowClick() { + // TODO BIT-467: Lock the app + sendEvent(AccountSecurityEvent.ShowToast("Lock the app.".asText())) + } + + private fun handleLoginRequestToggle(action: AccountSecurityAction.LoginRequestToggle) { + // TODO BIT-466: Persist pending login requests state + mutableStateFlow.update { it.copy(isApproveLoginRequestsEnabled = action.enabled) } + sendEvent(AccountSecurityEvent.ShowToast("Handle Login requests on this device.".asText())) + } + + private fun handleLogoutClick() { + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.ConfirmLogout) } + } + + private fun handlePendingLoginRequestsClick() { + // TODO BIT-466: Implement pending login requests UI + sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + } + + private fun handleSessionTimeoutActionSelect( + action: AccountSecurityAction.SessionTimeoutActionSelect, + ) { + // TODO BIT-746: Implement session timeout action + mutableStateFlow.update { + it.copy( + dialog = null, + sessionTimeoutAction = action.sessionTimeoutAction, + ) + } + sendEvent(AccountSecurityEvent.ShowToast("Not yet implemented.".asText())) + } + + private fun handleSessionTimeoutActionClick() { + 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())) + } + + private fun handleUnlockWithBiometricToggled( + action: AccountSecurityAction.UnlockWithBiometricToggle, + ) { + // TODO Display alert + mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = action.enabled) } + sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with biometrics.".asText())) + } + + private fun handleUnlockWithPinToggle(action: AccountSecurityAction.UnlockWithPinToggle) { + // TODO BIT-974: Display alert + mutableStateFlow.update { it.copy(isUnlockWithPinEnabled = action.enabled) } + sendEvent(AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText())) } } @@ -62,9 +161,39 @@ class AccountSecurityViewModel @Inject constructor( */ @Parcelize data class AccountSecurityState( - val shouldShowConfirmLogoutDialog: Boolean, + val dialog: AccountSecurityDialog?, + val isApproveLoginRequestsEnabled: Boolean, + val isUnlockWithBiometricsEnabled: Boolean, + val isUnlockWithPinEnabled: Boolean, + val sessionTimeout: Text, + val sessionTimeoutAction: SessionTimeoutAction, ) : Parcelable +/** + * Representation of the dialogs that can be displayed on account security screen. + */ +sealed class AccountSecurityDialog : Parcelable { + /** + * Allows the user to confirm that they want to logout. + */ + @Parcelize + data object ConfirmLogout : AccountSecurityDialog() + + /** + * Allows the user to select a session timeout action. + */ + @Parcelize + data object SessionTimeoutAction : AccountSecurityDialog() +} + +/** + * A representation of the Session timeout action. + */ +enum class SessionTimeoutAction(val text: Text) { + LOCK(text = R.string.lock.asText()), + LOG_OUT(text = R.string.log_out.asText()), +} + /** * Models events for the account security screen. */ @@ -73,29 +202,105 @@ sealed class AccountSecurityEvent { * Navigate back. */ data object NavigateBack : AccountSecurityEvent() + + /** + * Displays a toast with the given [Text]. + */ + data class ShowToast( + val text: Text, + ) : AccountSecurityEvent() } /** * Models actions for the account security screen. */ sealed class AccountSecurityAction { + + /** + * User clicked account fingerprint phrase. + */ + data object AccountFingerprintPhraseClick : AccountSecurityAction() + /** * User clicked back button. */ data object BackClick : AccountSecurityAction() + /** + * User clicked change master password. + */ + data object ChangeMasterPasswordClick : AccountSecurityAction() + /** * User confirmed they want to logout. */ data object ConfirmLogoutClick : AccountSecurityAction() /** - * User dismissed the confirm logout dialog. + * User clicked delete account. + */ + data object DeleteAccountClick : AccountSecurityAction() + + /** + * User dismissed the currently displayed dialog. */ data object DismissDialog : AccountSecurityAction() + /** + * User clicked lock now. + */ + data object LockNowClick : AccountSecurityAction() + + /** + * User toggled the login request switch. + */ + data class LoginRequestToggle( + val enabled: Boolean, + ) : AccountSecurityAction() + /** * User clicked log out. */ data object LogoutClick : AccountSecurityAction() + + /** + * User clicked pending login requests. + */ + data object PendingLoginRequestsClick : AccountSecurityAction() + + /** + * User selected a [SessionTimeoutAction]. + */ + data class SessionTimeoutActionSelect( + val sessionTimeoutAction: SessionTimeoutAction, + ) : AccountSecurityAction() + + /** + * User clicked session timeout action. + */ + data object SessionTimeoutActionClick : AccountSecurityAction() + + /** + * User clicked session timeout. + */ + data object SessionTimeoutClick : AccountSecurityAction() + + /** + * User clicked two-step login. + */ + data object TwoStepLoginClick : AccountSecurityAction() + + /** + * User toggled the unlock with biometrics switch. + */ + data class UnlockWithBiometricToggle( + val enabled: Boolean, + ) : AccountSecurityAction() + + /** + * User toggled the unlock with pin switch. + */ + data class UnlockWithPinToggle( + val enabled: Boolean, + ) : AccountSecurityAction() } 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 141bc9064..4ad15dbf7 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 @@ -2,94 +2,227 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest -import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.AccountSecurityAction.ConfirmLogoutClick -import com.x8bit.bitwarden.ui.platform.feature.settings.accountsecurity.AccountSecurityAction.DismissDialog +import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler +import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.every +import io.mockk.just import io.mockk.mockk +import io.mockk.runs import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test class AccountSecurityScreenTest : BaseComposeTest() { - @Test - fun `on Log out click should send LogoutClick`() { - val viewModel: AccountSecurityViewModel = mockk { - every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) - every { eventFlow } returns emptyFlow() - every { trySendAction(AccountSecurityAction.LogoutClick) } returns Unit - } + private var onNavigateBackCalled = false + + private val intentHandler = mockk { + every { launchUri(any()) } just runs + } + private val mutableEventFlow = MutableSharedFlow( + extraBufferCapacity = Int.MAX_VALUE, + ) + private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + private val viewModel = mockk(relaxed = true) { + every { eventFlow } returns mutableEventFlow + every { stateFlow } returns mutableStateFlow + } + + @Before + fun setUp() { composeTestRule.setContent { AccountSecurityScreen( + onNavigateBack = { onNavigateBackCalled = true }, viewModel = viewModel, - onNavigateBack = { }, + intentHandler = intentHandler, ) } - composeTestRule.onNodeWithText("Log out").performClick() + } + + @Test + fun `on Log out click should send LogoutClick`() { + composeTestRule.onNodeWithText("Log out").performScrollTo().performClick() verify { viewModel.trySendAction(AccountSecurityAction.LogoutClick) } } + @Test + fun `on approve login requests toggle should send LoginRequestToggle`() { + composeTestRule + .onNodeWithText("Use this device to approve login requests made from other devices") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.LoginRequestToggle(true)) } + } + + @Test + fun `on approve login requests should be toggled on or off according to state`() { + composeTestRule + .onNodeWithText("Use this device to approve login requests made from other devices") + .assertIsOff() + mutableStateFlow.update { it.copy(isApproveLoginRequestsEnabled = true) } + composeTestRule + .onNodeWithText("Use this device to approve login requests made from other devices") + .assertIsOn() + } + + @Test + fun `on pending login requests click should send PendingLoginRequestsClick`() { + composeTestRule + .onNodeWithText("Pending login requests") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) } + } + + @Test + fun `on unlock with biometrics toggle should send UnlockWithBiometricToggle`() { + composeTestRule + .onNodeWithText("Unlock with Biometrics") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) } + } + + @Test + fun `on unlock with biometrics should be toggled on or off according to state`() { + composeTestRule.onNodeWithText("Unlock with Biometrics").assertIsOff() + mutableStateFlow.update { it.copy(isUnlockWithBiometricsEnabled = true) } + composeTestRule.onNodeWithText("Unlock with Biometrics").assertIsOn() + } + + @Test + fun `on unlock with pin toggle should send UnlockWithPinToggle`() { + composeTestRule + .onNodeWithText("Unlock with PIN code") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(true)) } + } + + @Test + fun `on unlock with pin code should be toggled on or off according to state`() { + composeTestRule.onNodeWithText("Unlock with PIN code").assertIsOff() + mutableStateFlow.update { it.copy(isUnlockWithPinEnabled = true) } + 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()) } + composeTestRule + .onAllNodesWithText("Session timeout") + .filterToOne(hasClickAction()) + .performScrollTo() + .assertTextEquals("Session timeout", "30 Minutes") + } + + @Test + fun `on session timeout action click should send SessionTimeoutActionClick`() { + composeTestRule + .onNodeWithText("Session timeout action") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionClick) } + } + + @Test + fun `session timeout action should be updated on or off according to state`() { + composeTestRule + .onNodeWithText("Session timeout action") + .performScrollTo() + .assertTextEquals("Session timeout action", "Lock") + mutableStateFlow.update { it.copy(sessionTimeoutAction = SessionTimeoutAction.LOG_OUT) } + composeTestRule + .onNodeWithText("Session timeout action") + .performScrollTo() + .assertTextEquals("Session timeout action", "Log out") + } + + @Test + fun `session timeout action dialog should be displayed to state`() { + composeTestRule.onNodeWithText("Vault timeout action").assertDoesNotExist() + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.SessionTimeoutAction) } + composeTestRule.onNodeWithText("Vault timeout action").assertIsDisplayed() + } + + @Test + fun `on two-step login click should send TwoStepLoginClick`() { + composeTestRule + .onNodeWithText("Two-step login") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.TwoStepLoginClick) } + } + + @Test + fun `on change master password click should send ChangeMasterPasswordClick`() { + composeTestRule + .onNodeWithText("Change master password") + .performScrollTo() + .performClick() + verify { viewModel.trySendAction(AccountSecurityAction.ChangeMasterPasswordClick) } + } + + @Test + fun `on Lock now click should send LockNowClick`() { + composeTestRule.onNodeWithText("Lock now").performScrollTo().performClick() + verify { viewModel.trySendAction(AccountSecurityAction.LockNowClick) } + } + + @Test + fun `on delete account click should send DeleteAccountClick`() { + composeTestRule.onNodeWithText("Delete account").performScrollTo().performClick() + verify { viewModel.trySendAction(AccountSecurityAction.DeleteAccountClick) } + } + @Test fun `on back click should send BackClick`() { - val viewModel: AccountSecurityViewModel = mockk { - every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) - every { eventFlow } returns emptyFlow() - every { trySendAction(AccountSecurityAction.BackClick) } returns Unit - } - composeTestRule.setContent { - AccountSecurityScreen( - viewModel = viewModel, - onNavigateBack = { }, - ) - } composeTestRule.onNodeWithContentDescription("Back").performClick() verify { viewModel.trySendAction(AccountSecurityAction.BackClick) } } @Test - fun `on NavigateAccountSecurity should call onNavigateToAccountSecurity`() { - var haveCalledNavigateBack = false - val viewModel = mockk { - every { stateFlow } returns MutableStateFlow(DEFAULT_STATE) - every { eventFlow } returns flowOf(AccountSecurityEvent.NavigateBack) - } - composeTestRule.setContent { - AccountSecurityScreen( - viewModel = viewModel, - onNavigateBack = { haveCalledNavigateBack = true }, - ) - } - assertTrue(haveCalledNavigateBack) + fun `on NavigateBack should call onNavigateBack`() { + mutableEventFlow.tryEmit(AccountSecurityEvent.NavigateBack) + assertTrue(onNavigateBackCalled) } @Test fun `confirm dialog be shown or hidden according to the state`() { - val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) - val viewModel = mockk { - every { stateFlow } returns mutableStateFlow - every { eventFlow } returns emptyFlow() - every { trySendAction(ConfirmLogoutClick) } returns Unit - } - composeTestRule.setContent { - AccountSecurityScreen( - viewModel = viewModel, - onNavigateBack = { }, - ) - } composeTestRule.onNode(isDialog()).assertDoesNotExist() - - mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) } - + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.ConfirmLogout) } composeTestRule .onNodeWithText("Yes") .assert(hasAnyAncestor(isDialog())) @@ -106,51 +239,32 @@ class AccountSecurityScreenTest : BaseComposeTest() { @Test fun `on confirm logout click should send ConfirmLogoutClick`() { - val viewModel = mockk { - every { stateFlow } returns MutableStateFlow( - DEFAULT_STATE.copy(shouldShowConfirmLogoutDialog = true), - ) - every { eventFlow } returns emptyFlow() - every { trySendAction(ConfirmLogoutClick) } returns Unit - } - composeTestRule.setContent { - AccountSecurityScreen( - viewModel = viewModel, - onNavigateBack = { }, - ) - } + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.ConfirmLogout) } composeTestRule .onNodeWithText("Yes") .assert(hasAnyAncestor(isDialog())) .performClick() - verify { viewModel.trySendAction(ConfirmLogoutClick) } + verify { viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick) } } @Test fun `on cancel click should send DismissDialog`() { - val viewModel = mockk { - every { stateFlow } returns MutableStateFlow( - DEFAULT_STATE.copy(shouldShowConfirmLogoutDialog = true), - ) - every { eventFlow } returns emptyFlow() - every { trySendAction(DismissDialog) } returns Unit - } - composeTestRule.setContent { - AccountSecurityScreen( - viewModel = viewModel, - onNavigateBack = { }, - ) - } + mutableStateFlow.update { it.copy(dialog = AccountSecurityDialog.ConfirmLogout) } composeTestRule .onNodeWithText("Cancel") .assert(hasAnyAncestor(isDialog())) .performClick() - verify { viewModel.trySendAction(DismissDialog) } + verify { viewModel.trySendAction(AccountSecurityAction.DismissDialog) } } companion object { private val DEFAULT_STATE = AccountSecurityState( - shouldShowConfirmLogoutDialog = false, + dialog = null, + isApproveLoginRequestsEnabled = false, + isUnlockWithBiometricsEnabled = false, + isUnlockWithPinEnabled = false, + sessionTimeout = "15 Minutes".asText(), + 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 019a28398..a8a3308cc 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 @@ -4,30 +4,38 @@ import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class AccountSecurityViewModelTest : BaseViewModelTest() { @Test fun `initial state should be correct`() { - val viewModel = AccountSecurityViewModel( - savedStateHandle = SavedStateHandle(), - authRepository = mockk(), - ) + val viewModel = createViewModel() assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) } + @Test + fun `on AccountFingerprintPhraseClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.AccountFingerprintPhraseClick) + assertEquals( + AccountSecurityEvent.ShowToast("Display fingerprint phrase.".asText()), + awaitItem(), + ) + } + } + @Test fun `on BackClick should emit NavigateBack`() = runTest { - val viewModel = AccountSecurityViewModel( - savedStateHandle = SavedStateHandle(), - authRepository = mockk(), - ) + val viewModel = createViewModel() viewModel.eventFlow.test { viewModel.trySendAction(AccountSecurityAction.BackClick) assertEquals(AccountSecurityEvent.NavigateBack, awaitItem()) @@ -35,63 +43,203 @@ class AccountSecurityViewModelTest : BaseViewModelTest() { } @Test - fun `on LogoutClick should show confirm log out dialog`() = runTest { - val viewModel = AccountSecurityViewModel( - savedStateHandle = SavedStateHandle(), - authRepository = mockk(), - ) - viewModel.trySendAction(AccountSecurityAction.LogoutClick) - viewModel.stateFlow.test { + fun `on ChangeMasterPasswordClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.ChangeMasterPasswordClick) assertEquals( - DEFAULT_STATE.copy( - shouldShowConfirmLogoutDialog = true, - ), + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), awaitItem(), ) } } + @Test + fun `on DeleteAccountClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.DeleteAccountClick) + assertEquals( + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `on DismissSessionTimeoutActionDialog should update shouldShowSessionTimeoutActionDialog`() = + runTest { + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.DismissDialog) + assertEquals(DEFAULT_STATE.copy(dialog = null), viewModel.stateFlow.value) + } + + @Test + fun `on LockNowClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.LockNowClick) + assertEquals(AccountSecurityEvent.ShowToast("Lock the app.".asText()), awaitItem()) + } + } + + @Test + fun `on LoginRequestToggle should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.LoginRequestToggle(true)) + assertEquals( + AccountSecurityEvent.ShowToast("Handle Login requests on this device.".asText()), + awaitItem(), + ) + } + viewModel.stateFlow.test { + assertTrue(awaitItem().isApproveLoginRequestsEnabled) + } + } + + @Test + fun `on PendingLoginRequestsClick should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.PendingLoginRequestsClick) + assertEquals( + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + } + + @Test + fun `on SessionTimeoutActionSelect should update session timeout action`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction( + AccountSecurityAction.SessionTimeoutActionSelect(SessionTimeoutAction.LOG_OUT), + ) + assertEquals( + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + assertEquals( + DEFAULT_STATE.copy(dialog = null, sessionTimeoutAction = SessionTimeoutAction.LOG_OUT), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on SessionTimeoutActionClick should update shouldShowSessionTimeoutActionDialog`() = + runTest { + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.SessionTimeoutActionClick) + assertEquals( + DEFAULT_STATE.copy(dialog = AccountSecurityDialog.SessionTimeoutAction), + viewModel.stateFlow.value, + ) + } + + @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() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.TwoStepLoginClick) + assertEquals( + AccountSecurityEvent.ShowToast("Not yet implemented.".asText()), + awaitItem(), + ) + } + } + + @Test + fun `on UnlockWithBiometricToggle should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.UnlockWithBiometricToggle(true)) + assertEquals( + AccountSecurityEvent.ShowToast("Handle unlock with biometrics.".asText()), + awaitItem(), + ) + } + assertEquals( + DEFAULT_STATE.copy(isUnlockWithBiometricsEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on UnlockWithPinToggle should emit ShowToast`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.trySendAction(AccountSecurityAction.UnlockWithPinToggle(true)) + assertEquals( + AccountSecurityEvent.ShowToast("Handle unlock with pin.".asText()), + awaitItem(), + ) + } + assertEquals( + DEFAULT_STATE.copy(isUnlockWithPinEnabled = true), + viewModel.stateFlow.value, + ) + } + + @Test + fun `on LogoutClick should show confirm log out dialog`() = runTest { + val viewModel = createViewModel() + viewModel.trySendAction(AccountSecurityAction.LogoutClick) + assertEquals( + DEFAULT_STATE.copy(dialog = AccountSecurityDialog.ConfirmLogout), + viewModel.stateFlow.value, + ) + } + @Test fun `on ConfirmLogoutClick should call logout and hide confirm dialog`() = runTest { val authRepository: AuthRepository = mockk { every { logout() } returns Unit } - val viewModel = AccountSecurityViewModel( - savedStateHandle = SavedStateHandle(), - authRepository = authRepository, - ) + val viewModel = createViewModel(authRepository = authRepository) viewModel.trySendAction(AccountSecurityAction.ConfirmLogoutClick) - viewModel.stateFlow.test { - assertEquals( - DEFAULT_STATE.copy( - shouldShowConfirmLogoutDialog = false, - ), - awaitItem(), - ) - } + assertEquals(DEFAULT_STATE.copy(dialog = null), viewModel.stateFlow.value) verify { authRepository.logout() } } @Test fun `on DismissDialog should hide dialog`() = runTest { - val viewModel = AccountSecurityViewModel( - savedStateHandle = SavedStateHandle(), - authRepository = mockk(), - ) + val viewModel = createViewModel() viewModel.trySendAction(AccountSecurityAction.DismissDialog) - viewModel.stateFlow.test { - assertEquals( - DEFAULT_STATE.copy( - shouldShowConfirmLogoutDialog = false, - ), - awaitItem(), - ) - } + assertEquals(DEFAULT_STATE.copy(dialog = null), viewModel.stateFlow.value) } + private fun createViewModel( + authRepository: AuthRepository = mockk(relaxed = true), + savedStateHandle: SavedStateHandle = SavedStateHandle(), + ): AccountSecurityViewModel = AccountSecurityViewModel( + authRepository = authRepository, + savedStateHandle = savedStateHandle, + ) + companion object { private val DEFAULT_STATE = AccountSecurityState( - shouldShowConfirmLogoutDialog = false, + dialog = null, + isApproveLoginRequestsEnabled = false, + isUnlockWithBiometricsEnabled = false, + isUnlockWithPinEnabled = false, + sessionTimeout = "15 Minutes".asText(), + sessionTimeoutAction = SessionTimeoutAction.LOCK, ) } }