BIT-926: account security UI (#193)

This commit is contained in:
David Perez 2023-11-01 20:06:22 -05:00 committed by Álison Fernandes
parent 5dbe07a2cc
commit 3d28cd1100
4 changed files with 803 additions and 163 deletions

View file

@ -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 }) },
)
}
}
}

View file

@ -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<AccountSecurityState, AccountSecurityEvent, AccountSecurityAction>(
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()
}

View file

@ -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<IntentHandler> {
every { launchUri(any()) } just runs
}
private val mutableEventFlow = MutableSharedFlow<AccountSecurityEvent>(
extraBufferCapacity = Int.MAX_VALUE,
)
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
private val viewModel = mockk<AccountSecurityViewModel>(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<AccountSecurityViewModel> {
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<AccountSecurityViewModel> {
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<AccountSecurityViewModel> {
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<AccountSecurityViewModel> {
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,
)
}
}

View file

@ -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,
)
}
}