mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 07:05:35 +03:00
BIT-926: account security UI (#193)
This commit is contained in:
parent
5dbe07a2cc
commit
3d28cd1100
4 changed files with 803 additions and 163 deletions
|
@ -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 }) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
private fun handleLogoutClick() {
|
||||
mutableStateFlow.update { it.copy(shouldShowConfirmLogoutDialog = true) }
|
||||
AccountSecurityAction.SessionTimeoutActionClick -> handleSessionTimeoutActionClick()
|
||||
AccountSecurityAction.SessionTimeoutClick -> handleSessionTimeoutClick()
|
||||
AccountSecurityAction.TwoStepLoginClick -> handleTwoStepLoginClick()
|
||||
is AccountSecurityAction.UnlockWithBiometricToggle -> {
|
||||
handleUnlockWithBiometricToggled(action)
|
||||
}
|
||||
|
||||
is AccountSecurityAction.UnlockWithPinToggle -> handleUnlockWithPinToggle(action)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 BackClick should emit NavigateBack`() = runTest {
|
||||
val viewModel = AccountSecurityViewModel(
|
||||
savedStateHandle = SavedStateHandle(),
|
||||
authRepository = mockk(),
|
||||
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 = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue