mirror of
https://github.com/bitwarden/android.git
synced 2024-11-24 18:36:32 +03:00
BIT-999: Add UI for Vault Unlock screen (#243)
This commit is contained in:
parent
2f525513a2
commit
00e249dab2
8 changed files with 892 additions and 22 deletions
|
@ -12,19 +12,18 @@ import com.x8bit.bitwarden.data.auth.repository.model.LoginResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult
|
||||
import com.x8bit.bitwarden.data.auth.repository.util.generateUriForCaptcha
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
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 com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.net.URI
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
@ -282,23 +281,3 @@ sealed class LoginAction {
|
|||
) : Internal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [Environment.label] for non-custom values. Otherwise returns the host of the
|
||||
* custom base URL.
|
||||
*/
|
||||
private val Environment.labelOrBaseUrlHost: Text
|
||||
get() = when (this) {
|
||||
is Environment.Us -> this.label
|
||||
is Environment.Eu -> this.label
|
||||
is Environment.SelfHosted -> {
|
||||
// Grab the domain
|
||||
// Ex:
|
||||
// - "https://www.abc.com/path-1/path-1" -> "www.abc.com"
|
||||
URI
|
||||
.create(this.environmentUrlData.base)
|
||||
.host
|
||||
.orEmpty()
|
||||
.asText()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavOptions
|
||||
import androidx.navigation.compose.composable
|
||||
import com.x8bit.bitwarden.ui.platform.theme.TransitionProviders
|
||||
|
||||
private const val VAULT_UNLOCK: String = "vault_unlock"
|
||||
|
||||
/**
|
||||
* Navigate to the Vault Unlock screen.
|
||||
*/
|
||||
fun NavController.navigateToVaultUnlock(
|
||||
navOptions: NavOptions? = null,
|
||||
) {
|
||||
navigate(VAULT_UNLOCK, navOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the Vault Unlock screen to the nav graph.
|
||||
*/
|
||||
fun NavGraphBuilder.vaultUnlockDestinations() {
|
||||
composable(
|
||||
route = VAULT_UNLOCK,
|
||||
enterTransition = TransitionProviders.Enter.slideUp,
|
||||
exitTransition = TransitionProviders.Exit.slideDown,
|
||||
popEnterTransition = TransitionProviders.Enter.slideUp,
|
||||
popExitTransition = TransitionProviders.Exit.slideDown,
|
||||
) {
|
||||
VaultUnlockScreen()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.rememberTopAppBarState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenAccountSwitcher
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
|
||||
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
/**
|
||||
* The top level composable for the Vault Unlock screen.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun VaultUnlockScreen(
|
||||
viewModel: VaultUnlockViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
EventsEffect(viewModel = viewModel) { event ->
|
||||
when (event) {
|
||||
is VaultUnlockEvent.ShowToast -> {
|
||||
Toast.makeText(context, event.text(resources), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
VaultUnlockEvent.NavigateToLoginScreen -> {
|
||||
// TODO: Handle adding accounts (BIT-853)
|
||||
Toast.makeText(context, "Not yet implemented.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var accountMenuVisible by rememberSaveable { mutableStateOf(false) }
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(
|
||||
state = rememberTopAppBarState(),
|
||||
canScroll = { !accountMenuVisible },
|
||||
)
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
is VaultUnlockState.VaultUnlockDialog.Error -> BitwardenBasicDialog(
|
||||
visibilityState = BasicDialogState.Shown(
|
||||
title = R.string.an_error_has_occurred.asText(),
|
||||
message = dialog.message,
|
||||
),
|
||||
onDismissRequest = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.DismissDialog) }
|
||||
},
|
||||
)
|
||||
|
||||
VaultUnlockState.VaultUnlockDialog.Loading -> BitwardenLoadingDialog(
|
||||
visibilityState = LoadingDialogState.Shown(R.string.loading.asText()),
|
||||
)
|
||||
|
||||
null -> Unit
|
||||
}
|
||||
|
||||
BitwardenScaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
BitwardenTopAppBar(
|
||||
title = stringResource(id = R.string.verify_master_password),
|
||||
scrollBehavior = scrollBehavior,
|
||||
navigationIcon = null,
|
||||
actions = {
|
||||
BitwardenAccountActionItem(
|
||||
initials = state.initials,
|
||||
color = state.avatarColor,
|
||||
onClick = { accountMenuVisible = !accountMenuVisible },
|
||||
)
|
||||
BitwardenOverflowActionItem()
|
||||
},
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Box {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
BitwardenPasswordField(
|
||||
label = stringResource(id = R.string.master_password),
|
||||
value = state.passwordInput,
|
||||
onValueChange = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(it)) }
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Text(
|
||||
text = stringResource(id = R.string.vault_locked_master_password),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(
|
||||
id = R.string.logged_in_as_on,
|
||||
state.email,
|
||||
state.environmentUrl(),
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
BitwardenFilledButton(
|
||||
label = stringResource(id = R.string.unlock),
|
||||
onClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
||||
},
|
||||
isEnabled = state.passwordInput.isNotEmpty(),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
Spacer(modifier = Modifier.navigationBarsPadding())
|
||||
}
|
||||
|
||||
BitwardenAccountSwitcher(
|
||||
isVisible = accountMenuVisible,
|
||||
accountSummaries = state.accountSummaries.toImmutableList(),
|
||||
onAccountSummaryClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(it)) }
|
||||
},
|
||||
onAddAccountClick = remember(viewModel) {
|
||||
{ viewModel.trySendAction(VaultUnlockAction.AddAccountClick) }
|
||||
},
|
||||
onDismissRequest = { accountMenuVisible = false },
|
||||
topAppBarScrollBehavior = scrollBehavior,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,236 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
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 com.x8bit.bitwarden.ui.platform.base.util.hexToColor
|
||||
import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val KEY_STATE = "state"
|
||||
|
||||
/**
|
||||
* Manages application state for the initial vault unlock screen.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VaultUnlockViewModel @Inject constructor(
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val vaultRepo: VaultRepository,
|
||||
environmentRepo: EnvironmentRepository,
|
||||
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
|
||||
initialState = savedStateHandle[KEY_STATE] ?: VaultUnlockState(
|
||||
accountSummaries = emptyList(),
|
||||
avatarColorString = "0000FF",
|
||||
initials = "BW",
|
||||
email = "bit@bitwarden.com",
|
||||
dialog = null,
|
||||
environmentUrl = environmentRepo.environment.labelOrBaseUrlHost,
|
||||
passwordInput = "",
|
||||
),
|
||||
) {
|
||||
init {
|
||||
stateFlow
|
||||
.onEach { savedStateHandle[KEY_STATE] = it }
|
||||
.launchIn(viewModelScope)
|
||||
environmentRepo
|
||||
.environmentStateFlow
|
||||
.onEach { environment ->
|
||||
mutableStateFlow.update {
|
||||
it.copy(environmentUrl = environment.labelOrBaseUrlHost)
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
override fun handleAction(action: VaultUnlockAction) {
|
||||
when (action) {
|
||||
VaultUnlockAction.AddAccountClick -> handleAddAccountClick()
|
||||
VaultUnlockAction.DismissDialog -> handleDismissDialog()
|
||||
is VaultUnlockAction.PasswordInputChanged -> handlePasswordInputChanged(action)
|
||||
is VaultUnlockAction.SwitchAccountClick -> handleSwitchAccountClick(action)
|
||||
VaultUnlockAction.UnlockClick -> handleUnlockClick()
|
||||
is VaultUnlockAction.Internal.ReceiveVaultUnlockResult -> {
|
||||
handleReceiveVaultUnlockResult(action)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleAddAccountClick() {
|
||||
sendEvent(VaultUnlockEvent.NavigateToLoginScreen)
|
||||
}
|
||||
|
||||
private fun handleDismissDialog() {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
private fun handlePasswordInputChanged(action: VaultUnlockAction.PasswordInputChanged) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(passwordInput = action.passwordInput)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSwitchAccountClick(action: VaultUnlockAction.SwitchAccountClick) {
|
||||
// TODO: Handle switching accounts (BIT-853)
|
||||
sendEvent(VaultUnlockEvent.ShowToast("Not yet implemented.".asText()))
|
||||
}
|
||||
|
||||
private fun handleUnlockClick() {
|
||||
mutableStateFlow.update { it.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading) }
|
||||
viewModelScope.launch {
|
||||
val vaultUnlockResult = vaultRepo.unlockVaultAndSync(
|
||||
mutableStateFlow.value.passwordInput,
|
||||
)
|
||||
sendAction(VaultUnlockAction.Internal.ReceiveVaultUnlockResult(vaultUnlockResult))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceiveVaultUnlockResult(
|
||||
action: VaultUnlockAction.Internal.ReceiveVaultUnlockResult,
|
||||
) {
|
||||
when (action.vaultUnlockResult) {
|
||||
VaultUnlockResult.AuthenticationError -> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||
R.string.invalid_master_password.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VaultUnlockResult.GenericError,
|
||||
VaultUnlockResult.InvalidStateError,
|
||||
-> {
|
||||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||
R.string.generic_error_message.asText(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VaultUnlockResult.Success -> {
|
||||
mutableStateFlow.update { it.copy(dialog = null) }
|
||||
// Don't do anything, we'll navigate to the right place.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models state of the vault unlock screen.
|
||||
*/
|
||||
@Parcelize
|
||||
data class VaultUnlockState(
|
||||
val accountSummaries: List<AccountSummary>,
|
||||
private val avatarColorString: String,
|
||||
val initials: String,
|
||||
val email: String,
|
||||
val environmentUrl: Text,
|
||||
val dialog: VaultUnlockDialog?,
|
||||
val passwordInput: String,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* The [Color] of the avatar.
|
||||
*/
|
||||
val avatarColor: Color get() = avatarColorString.hexToColor()
|
||||
|
||||
/**
|
||||
* Represents the various dialogs the vault unlock screen can display.
|
||||
*/
|
||||
sealed class VaultUnlockDialog : Parcelable {
|
||||
/**
|
||||
* Represents an error dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val message: Text,
|
||||
) : VaultUnlockDialog()
|
||||
|
||||
/**
|
||||
* Represents a loading state dialog.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Loading : VaultUnlockDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models events for the vault unlock screen.
|
||||
*/
|
||||
sealed class VaultUnlockEvent {
|
||||
/**
|
||||
* Navigates to the login flow.
|
||||
*/
|
||||
data object NavigateToLoginScreen : VaultUnlockEvent()
|
||||
|
||||
/**
|
||||
* Displays a toast to the user.
|
||||
*/
|
||||
data class ShowToast(
|
||||
val text: Text,
|
||||
) : VaultUnlockEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Models actions for the vault unlock screen.
|
||||
*/
|
||||
sealed class VaultUnlockAction {
|
||||
/**
|
||||
* The user has clicked the add account button.
|
||||
*/
|
||||
data object AddAccountClick : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user dismissed the currently displayed dialog.
|
||||
*/
|
||||
data object DismissDialog : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has modified the password input.
|
||||
*/
|
||||
data class PasswordInputChanged(
|
||||
val passwordInput: String,
|
||||
) : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the an account to switch too.
|
||||
*/
|
||||
data class SwitchAccountClick(
|
||||
val accountSummary: AccountSummary,
|
||||
) : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* The user has clicked the unlock button.
|
||||
*/
|
||||
data object UnlockClick : VaultUnlockAction()
|
||||
|
||||
/**
|
||||
* Models actions that the [VaultUnlockViewModel] itself might send.
|
||||
*/
|
||||
sealed class Internal : VaultUnlockAction() {
|
||||
/**
|
||||
* Indicates a vault unlock result has been received.
|
||||
*/
|
||||
data class ReceiveVaultUnlockResult(
|
||||
val vaultUnlockResult: VaultUnlockResult,
|
||||
) : Internal()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
package com.x8bit.bitwarden.ui.platform.util
|
||||
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.Text
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import java.net.URI
|
||||
|
||||
/**
|
||||
* Returns the [Environment.label] for non-custom values. Otherwise returns the host of the
|
||||
* custom base URL.
|
||||
*/
|
||||
val Environment.labelOrBaseUrlHost: Text
|
||||
get() = when (this) {
|
||||
is Environment.Us -> this.label
|
||||
is Environment.Eu -> this.label
|
||||
is Environment.SelfHosted -> {
|
||||
// Grab the domain
|
||||
// Ex:
|
||||
// - "https://www.abc.com/path-1/path-1" -> "www.abc.com"
|
||||
URI
|
||||
.create(this.environmentUrlData.base)
|
||||
.host
|
||||
.orEmpty()
|
||||
.asText()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsEnabled
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class VaultUnlockScreenTest : BaseComposeTest() {
|
||||
|
||||
private val mutableEventFlow = MutableSharedFlow<VaultUnlockEvent>(
|
||||
extraBufferCapacity = Int.MAX_VALUE,
|
||||
)
|
||||
private val mutableStateFlow = MutableStateFlow(DEFAULT_STATE)
|
||||
private val viewModel = mockk<VaultUnlockViewModel>(relaxed = true) {
|
||||
every { eventFlow } returns mutableEventFlow
|
||||
every { stateFlow } returns mutableStateFlow
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
composeTestRule.setContent {
|
||||
VaultUnlockScreen(
|
||||
viewModel = viewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account icon click should show the account switcher`() {
|
||||
composeTestRule.onNodeWithText("active@bitwarden.com").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText("Add account").assertDoesNotExist()
|
||||
|
||||
composeTestRule.onNodeWithText("AU").performClick()
|
||||
|
||||
composeTestRule.onNodeWithText("active@bitwarden.com").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("locked@bitwarden.com").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithText("Add account").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `account click in the account switcher should send AccountSwitchClick and close switcher`() {
|
||||
// Open the Account Switcher
|
||||
composeTestRule.onNodeWithText("AU").performClick()
|
||||
|
||||
composeTestRule.onNodeWithText("locked@bitwarden.com").performClick()
|
||||
verify {
|
||||
viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(LOCKED_ACCOUNT_SUMMARY))
|
||||
}
|
||||
composeTestRule.onNodeWithText("locked@bitwarden.com").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `add account click in the account switcher should send AddAccountClick and close switcher`() {
|
||||
// Open the Account Switcher
|
||||
composeTestRule.onNodeWithText("AU").performClick()
|
||||
|
||||
composeTestRule.onNodeWithText("Add account").performClick()
|
||||
verify { viewModel.trySendAction(VaultUnlockAction.AddAccountClick) }
|
||||
composeTestRule.onNodeWithText("Add account").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `email state change should update logged in as text`() {
|
||||
val newEmail = "david@bitwarden.com"
|
||||
val textBeforeUpdate = "Logged in as ${DEFAULT_STATE.email} on $DEFAULT_ENVIRONMENT_URL."
|
||||
val textAfterUpdate = "Logged in as $newEmail on $DEFAULT_ENVIRONMENT_URL."
|
||||
composeTestRule.onNodeWithText(textBeforeUpdate).assertExists()
|
||||
composeTestRule.onNodeWithText(textAfterUpdate).assertDoesNotExist()
|
||||
mutableStateFlow.update { it.copy(email = newEmail) }
|
||||
composeTestRule.onNodeWithText(textBeforeUpdate).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(textAfterUpdate).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environment url state change should update logged in as text`() {
|
||||
val newEnvironmentUrl = "eu.bitwarden.com"
|
||||
val textBeforeUpdate = "Logged in as ${DEFAULT_STATE.email} on $DEFAULT_ENVIRONMENT_URL."
|
||||
val textAfterUpdate = "Logged in as ${DEFAULT_STATE.email} on $newEnvironmentUrl."
|
||||
composeTestRule.onNodeWithText(textBeforeUpdate).assertExists()
|
||||
composeTestRule.onNodeWithText(textAfterUpdate).assertDoesNotExist()
|
||||
mutableStateFlow.update { it.copy(environmentUrl = newEnvironmentUrl.asText()) }
|
||||
composeTestRule.onNodeWithText(textBeforeUpdate).assertDoesNotExist()
|
||||
composeTestRule.onNodeWithText(textAfterUpdate).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `password input state change should update unlock button enabled`() {
|
||||
composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsNotEnabled()
|
||||
mutableStateFlow.update { it.copy(passwordInput = "a") }
|
||||
composeTestRule.onNodeWithText("Unlock").performScrollTo().assertIsEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlock click should send UnlockClick action`() {
|
||||
mutableStateFlow.update { it.copy(passwordInput = "abdc1234") }
|
||||
composeTestRule
|
||||
.onNodeWithText("Unlock")
|
||||
.performScrollTo()
|
||||
.performClick()
|
||||
verify { viewModel.trySendAction(VaultUnlockAction.UnlockClick) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `master password change should send PasswordInputChanged action`() {
|
||||
val input = "abcd1234"
|
||||
composeTestRule
|
||||
.onNodeWithText("Master password")
|
||||
.performScrollTo()
|
||||
.performTextInput(input)
|
||||
verify {
|
||||
viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(input))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val DEFAULT_ENVIRONMENT_URL: String = "vault.bitwarden.com"
|
||||
|
||||
private val ACTIVE_ACCOUNT_SUMMARY = AccountSummary(
|
||||
userId = "activeUserId",
|
||||
name = "Active User",
|
||||
email = "active@bitwarden.com",
|
||||
avatarColorHex = "#aa00aa",
|
||||
status = AccountSummary.Status.ACTIVE,
|
||||
)
|
||||
|
||||
private val LOCKED_ACCOUNT_SUMMARY = AccountSummary(
|
||||
userId = "lockedUserId",
|
||||
name = "Locked User",
|
||||
email = "locked@bitwarden.com",
|
||||
avatarColorHex = "#00aaaa",
|
||||
status = AccountSummary.Status.LOCKED,
|
||||
)
|
||||
|
||||
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
|
||||
accountSummaries = persistentListOf(
|
||||
ACTIVE_ACCOUNT_SUMMARY,
|
||||
LOCKED_ACCOUNT_SUMMARY,
|
||||
),
|
||||
avatarColorString = "0000FF",
|
||||
dialog = null,
|
||||
email = "bit@bitwarden.com",
|
||||
environmentUrl = DEFAULT_ENVIRONMENT_URL.asText(),
|
||||
initials = "AU",
|
||||
passwordInput = "",
|
||||
)
|
|
@ -0,0 +1,207 @@
|
|||
package com.x8bit.bitwarden.ui.auth.feature.vaultunlock
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
|
||||
import com.x8bit.bitwarden.data.auth.repository.model.AccountSummary
|
||||
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VaultUnlockViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val environmentRepository = FakeEnvironmentRepository()
|
||||
private val vaultRepository = mockk<VaultRepository>()
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when not set`() {
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state should be correct when set`() {
|
||||
val state = DEFAULT_STATE.copy(
|
||||
initials = "WB",
|
||||
avatarColorString = "00FF00",
|
||||
)
|
||||
val viewModel = createViewModel(state = state)
|
||||
assertEquals(state, viewModel.stateFlow.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `environment url should update when environment repo emits an update`() {
|
||||
val viewModel = createViewModel()
|
||||
assertEquals(DEFAULT_STATE, viewModel.stateFlow.value)
|
||||
environmentRepository.environment = Environment.SelfHosted(
|
||||
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.eu"),
|
||||
)
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(environmentUrl = "vault.bitwarden.eu".asText()),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on AddAccountClick should emit NavigateToLoginScreen`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultUnlockAction.AddAccountClick)
|
||||
assertEquals(VaultUnlockEvent.NavigateToLoginScreen, awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on DismissDialog should clear the dialog state`() = runTest {
|
||||
val initialState = DEFAULT_STATE.copy(dialog = VaultUnlockState.VaultUnlockDialog.Loading)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
viewModel.trySendAction(VaultUnlockAction.DismissDialog)
|
||||
assertEquals(
|
||||
initialState.copy(dialog = null),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on PasswordInputChanged should update the password input state`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val password = "abcd1234"
|
||||
viewModel.trySendAction(VaultUnlockAction.PasswordInputChanged(passwordInput = password))
|
||||
assertEquals(
|
||||
DEFAULT_STATE.copy(passwordInput = password),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on SwitchAccountClick should emit ShowToast`() = runTest {
|
||||
val viewModel = createViewModel()
|
||||
val accountSummary = mockk<AccountSummary> {
|
||||
every { status } returns AccountSummary.Status.ACTIVE
|
||||
}
|
||||
viewModel.eventFlow.test {
|
||||
viewModel.trySendAction(VaultUnlockAction.SwitchAccountClick(accountSummary))
|
||||
assertEquals(VaultUnlockEvent.ShowToast("Not yet implemented.".asText()), awaitItem())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on UnlockClick should display error dialog on AuthenticationError`() = runTest {
|
||||
val password = "abcd1234"
|
||||
val initialState = DEFAULT_STATE.copy(passwordInput = password)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
coEvery {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
} returns VaultUnlockResult.AuthenticationError
|
||||
|
||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||
R.string.invalid_master_password.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on UnlockClick should display error dialog on GenericError`() = runTest {
|
||||
val password = "abcd1234"
|
||||
val initialState = DEFAULT_STATE.copy(passwordInput = password)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
coEvery {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
} returns VaultUnlockResult.GenericError
|
||||
|
||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||
R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on UnlockClick should display error dialog on InvalidStateError`() = runTest {
|
||||
val password = "abcd1234"
|
||||
val initialState = DEFAULT_STATE.copy(passwordInput = password)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
coEvery {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
} returns VaultUnlockResult.InvalidStateError
|
||||
|
||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||
assertEquals(
|
||||
initialState.copy(
|
||||
dialog = VaultUnlockState.VaultUnlockDialog.Error(
|
||||
R.string.generic_error_message.asText(),
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on UnlockClick should display clear dialog on success`() = runTest {
|
||||
val password = "abcd1234"
|
||||
val initialState = DEFAULT_STATE.copy(passwordInput = password)
|
||||
val viewModel = createViewModel(state = initialState)
|
||||
coEvery {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
} returns VaultUnlockResult.Success
|
||||
|
||||
viewModel.trySendAction(VaultUnlockAction.UnlockClick)
|
||||
assertEquals(
|
||||
initialState.copy(dialog = null),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
coVerify {
|
||||
vaultRepository.unlockVaultAndSync(password)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createViewModel(
|
||||
state: VaultUnlockState? = DEFAULT_STATE,
|
||||
environmentRepo: EnvironmentRepository = environmentRepository,
|
||||
vaultRepo: VaultRepository = vaultRepository,
|
||||
): VaultUnlockViewModel = VaultUnlockViewModel(
|
||||
savedStateHandle = SavedStateHandle().apply { set("state", state) },
|
||||
vaultRepo = vaultRepo,
|
||||
environmentRepo = environmentRepo,
|
||||
)
|
||||
}
|
||||
|
||||
private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState(
|
||||
accountSummaries = emptyList(),
|
||||
avatarColorString = "0000FF",
|
||||
email = "bit@bitwarden.com",
|
||||
initials = "BW",
|
||||
dialog = null,
|
||||
environmentUrl = Environment.Us.label,
|
||||
passwordInput = "",
|
||||
)
|
|
@ -0,0 +1,39 @@
|
|||
package com.x8bit.bitwarden.ui.platform.util
|
||||
|
||||
import com.x8bit.bitwarden.data.auth.datasource.disk.model.EnvironmentUrlDataJson
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class EnvironmentExtensionsTest {
|
||||
@Test
|
||||
fun `labelOrBaseUrlHost should correctly convert US environment to the correct label`() {
|
||||
val environment = Environment.Us
|
||||
assertEquals(
|
||||
environment.label,
|
||||
environment.labelOrBaseUrlHost,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labelOrBaseUrlHost should correctly convert EU environment to the correct label`() {
|
||||
val environment = Environment.Eu
|
||||
assertEquals(
|
||||
environment.label,
|
||||
environment.labelOrBaseUrlHost,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `labelOrBaseUrlHost should correctly convert self hosted environment to the correct label`() {
|
||||
val environment = Environment.SelfHosted(
|
||||
environmentUrlData = EnvironmentUrlDataJson(base = "https://vault.bitwarden.com"),
|
||||
)
|
||||
assertEquals(
|
||||
"vault.bitwarden.com".asText(),
|
||||
environment.labelOrBaseUrlHost,
|
||||
)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue