diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt index b366fcfd60..d46170d100 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepository.kt @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.auth.repository import com.x8bit.bitwarden.data.auth.datasource.sdk.model.PasswordStrength +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult @@ -17,6 +18,11 @@ interface AuthRepository { */ val authStateFlow: StateFlow + /** + * Emits updates for changes to the [UserState]. + */ + val userStateFlow: StateFlow + /** * Flow of the current [CaptchaCallbackTokenResult]. Subscribers should listen to the flow * in order to receive updates whenever [setCaptchaCallbackTokenResult] is called. diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt index 63ddbad922..be5858012c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryImpl.kt @@ -16,6 +16,7 @@ import com.x8bit.bitwarden.data.auth.datasource.sdk.util.toKdfTypeJson import com.x8bit.bitwarden.data.auth.repository.model.AuthState import com.x8bit.bitwarden.data.auth.repository.model.LoginResult import com.x8bit.bitwarden.data.auth.repository.model.RegisterResult +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.auth.repository.util.CaptchaCallbackTokenResult import com.x8bit.bitwarden.data.auth.repository.util.toSdkParams import com.x8bit.bitwarden.data.auth.repository.util.toUserState @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import javax.inject.Singleton @@ -75,6 +77,22 @@ class AuthRepositoryImpl constructor( initialValue = AuthState.Uninitialized, ) + override val userStateFlow: StateFlow = combine( + authDiskSource.userStateFlow, + vaultRepository.vaultStateFlow, + ) { userStateJson, vaultState -> + userStateJson?.toUserState(vaultState = vaultState) + } + .stateIn( + scope = scope, + started = SharingStarted.Eagerly, + initialValue = authDiskSource + .userState + ?.toUserState( + vaultState = vaultRepository.vaultStateFlow.value, + ), + ) + private val mutableCaptchaTokenFlow = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) override val captchaTokenResultFlow: Flow = @@ -132,6 +150,7 @@ class AuthRepositoryImpl constructor( .environmentUrlData, ) vaultRepository.unlockVault( + userId = userStateJson.activeUserId, email = userStateJson.activeAccount.profile.email, kdf = userStateJson.activeAccount.profile.toSdkParams(), userKey = loginResponse.key, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt new file mode 100644 index 0000000000..3795aa15a8 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.data.auth.repository.model + +import com.x8bit.bitwarden.data.auth.repository.model.UserState.Account + +/** + * Represents the overall "user state" of the current active user as well as any users that may be + * switched to. + * + * @property activeUserId The ID of the current active user. + * @property accounts A mapping between user IDs and the [Account] information associated with + * that user. + */ +data class UserState( + val activeUserId: String, + val accounts: List, +) { + init { + require(accounts.any { it.userId == activeUserId }) + } + + /** + * The [Account] associated with the current [activeUserId]. + */ + val activeAccount: Account + get() = accounts.first { it.userId == activeUserId } + + /** + * Basic account information about a given user. + * + * @property userId The ID of the user. + * @property email The user's email address. + * @property name The user's name (if applicable). + * @property avatarColorHex Hex color value for a user's avatar in the "#AARRGGBB" format. + * @property isVaultUnlocked Whether or not the user's vault is currently unlocked. + */ + data class Account( + val userId: String, + val name: String?, + val email: String, + val avatarColorHex: String, + val isVaultUnlocked: Boolean, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt index f3b1f5fc1a..7053233098 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensions.kt @@ -1,7 +1,9 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.vault.datasource.network.model.SyncResponseJson +import com.x8bit.bitwarden.data.vault.repository.model.VaultState /** * Updates the given [UserStateJson] with the data from the [syncResponse] to return a new @@ -31,3 +33,27 @@ fun UserStateJson.toUpdatedUserStateJson( }, ) } + +/** + * Converts the given [UserStateJson] to a [UserState] using the given [vaultState]. + */ +fun UserStateJson.toUserState( + vaultState: VaultState, +): UserState = + UserState( + activeUserId = this.activeUserId, + accounts = this + .accounts + .values + .map { accountJson -> + val userId = accountJson.profile.userId + UserState.Account( + userId = accountJson.profile.userId, + name = accountJson.profile.name, + email = accountJson.profile.email, + // TODO Calculate default color (BIT-1191) + avatarColorHex = accountJson.profile.avatarColorHex ?: "#00aaaa", + isVaultUnlocked = userId in vaultState.unlockedVaultUserIds, + ) + }, + ) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt index e8d961afa8..eea967f962 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepository.kt @@ -6,6 +6,7 @@ import com.bitwarden.core.Kdf import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import kotlinx.coroutines.flow.StateFlow @@ -19,6 +20,11 @@ interface VaultRepository { */ val vaultDataStateFlow: StateFlow> + /** + * Flow that represents the current vault state. + */ + val vaultStateFlow: StateFlow + /** * Flow that represents the current send data. */ @@ -56,6 +62,7 @@ interface VaultRepository { */ @Suppress("LongParameterList") suspend fun unlockVault( + userId: String, masterPassword: String, email: String, kdf: Kdf, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt index 91d6ecb259..983c36a6d8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryImpl.kt @@ -17,6 +17,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.SyncService import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList @@ -39,6 +40,7 @@ import kotlinx.coroutines.launch /** * Default implementation of [VaultRepository]. */ +@Suppress("TooManyFunctions") class VaultRepositoryImpl constructor( private val syncService: SyncService, private val vaultSdkSource: VaultSdkSource, @@ -55,12 +57,18 @@ class VaultRepositoryImpl constructor( private val vaultDataMutableStateFlow = MutableStateFlow>(DataState.Loading) + private val vaultMutableStateFlow = + MutableStateFlow(VaultState(unlockedVaultUserIds = emptySet())) + private val sendDataMutableStateFlow = MutableStateFlow>(DataState.Loading) override val vaultDataStateFlow: StateFlow> get() = vaultDataMutableStateFlow.asStateFlow() + override val vaultStateFlow: StateFlow + get() = vaultMutableStateFlow.asStateFlow() + override val sendDataStateFlow: StateFlow> get() = sendDataMutableStateFlow.asStateFlow() @@ -157,6 +165,7 @@ class VaultRepositoryImpl constructor( val privateKey = authDiskSource.getPrivateKey(userId = userState.activeUserId) ?: return VaultUnlockResult.InvalidStateError return unlockVault( + userId = userState.activeUserId, masterPassword = masterPassword, email = userState.activeAccount.profile.email, kdf = userState.activeAccount.profile.toSdkParams(), @@ -173,6 +182,7 @@ class VaultRepositoryImpl constructor( } override suspend fun unlockVault( + userId: String, masterPassword: String, email: String, kdf: Kdf, @@ -196,13 +206,31 @@ class VaultRepositoryImpl constructor( ) .fold( onFailure = { VaultUnlockResult.GenericError }, - onSuccess = { it.toVaultUnlockResult() }, + onSuccess = { initializeCryptoResult -> + initializeCryptoResult + .toVaultUnlockResult() + .also { + if (it is VaultUnlockResult.Success) { + setVaultToUnlocked(userId = userId) + } + } + }, ), ) } .onCompletion { willSyncAfterUnlock = false } .first() + // TODO: This is temporary. Eventually this needs to be based on the presence of various + // user keys but this will likely require SDK updates to support this (BIT-1190). + private fun setVaultToUnlocked(userId: String) { + vaultMutableStateFlow.update { + it.copy( + unlockedVaultUserIds = it.unlockedVaultUserIds + userId, + ) + } + } + private fun storeUserKeyAndPrivateKey( userKey: String?, privateKey: String?, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultState.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultState.kt new file mode 100644 index 0000000000..ec59e41af9 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/model/VaultState.kt @@ -0,0 +1,10 @@ +package com.x8bit.bitwarden.data.vault.repository.model + +/** + * General description of the vault across multiple users. + * + * @property unlockedVaultUserIds The user IDs for all users that currently have unlocked vaults. + */ +data class VaultState( + val unlockedVaultUserIds: Set, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockNavigation.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockNavigation.kt index 90c066a23a..d548d5c0c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockNavigation.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockNavigation.kt @@ -6,7 +6,7 @@ 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" +const val VAULT_UNLOCK_ROUTE: String = "vault_unlock" /** * Navigate to the Vault Unlock screen. @@ -14,7 +14,7 @@ private const val VAULT_UNLOCK: String = "vault_unlock" fun NavController.navigateToVaultUnlock( navOptions: NavOptions? = null, ) { - navigate(VAULT_UNLOCK, navOptions) + navigate(VAULT_UNLOCK_ROUTE, navOptions) } /** @@ -22,7 +22,7 @@ fun NavController.navigateToVaultUnlock( */ fun NavGraphBuilder.vaultUnlockDestinations() { composable( - route = VAULT_UNLOCK, + route = VAULT_UNLOCK_ROUTE, enterTransition = TransitionProviders.Enter.slideUp, exitTransition = TransitionProviders.Exit.slideDown, popEnterTransition = TransitionProviders.Enter.slideUp, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index 12a9629ae2..57c51099cc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -5,6 +5,7 @@ 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.AuthRepository 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 @@ -14,6 +15,9 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.hexToColor import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.platform.util.labelOrBaseUrlHost +import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -30,18 +34,24 @@ private const val KEY_STATE = "state" @HiltViewModel class VaultUnlockViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, + private val authRepository: AuthRepository, private val vaultRepo: VaultRepository, environmentRepo: EnvironmentRepository, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] ?: VaultUnlockState( - accountSummaries = emptyList(), - avatarColorString = "0000FF", - initials = "BW", - email = "bit@bitwarden.com", - dialog = null, - environmentUrl = environmentRepo.environment.labelOrBaseUrlHost, - passwordInput = "", - ), + initialState = savedStateHandle[KEY_STATE] ?: run { + val userState = requireNotNull(authRepository.userStateFlow.value) + val accountSummaries = userState.toAccountSummaries() + val activeAccountSummary = userState.toActiveAccountSummary() + VaultUnlockState( + accountSummaries = accountSummaries, + avatarColorString = activeAccountSummary.avatarColorHex, + initials = activeAccountSummary.initials, + email = activeAccountSummary.email, + dialog = null, + environmentUrl = environmentRepo.environment.labelOrBaseUrlHost, + passwordInput = "", + ) + }, ) { init { stateFlow diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt index d9f4f042d5..d1bf7411cd 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreen.kt @@ -13,6 +13,9 @@ import androidx.navigation.navOptions import com.x8bit.bitwarden.ui.auth.feature.auth.AUTH_GRAPH_ROUTE import com.x8bit.bitwarden.ui.auth.feature.auth.authGraph import com.x8bit.bitwarden.ui.auth.feature.auth.navigateToAuthGraph +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.VAULT_UNLOCK_ROUTE +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.navigateToVaultUnlock +import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.vaultUnlockDestinations import com.x8bit.bitwarden.ui.platform.feature.splash.SPLASH_ROUTE import com.x8bit.bitwarden.ui.platform.feature.splash.navigateToSplash import com.x8bit.bitwarden.ui.platform.feature.splash.splashDestination @@ -47,12 +50,14 @@ fun RootNavScreen( ) { splashDestination() authGraph(navController) + vaultUnlockDestinations() vaultUnlockedGraph(navController) } val targetRoute = when (state) { RootNavState.Auth -> AUTH_GRAPH_ROUTE RootNavState.Splash -> SPLASH_ROUTE + RootNavState.VaultLocked -> VAULT_UNLOCK_ROUTE RootNavState.VaultUnlocked -> VAULT_UNLOCKED_GRAPH_ROUTE } val currentRoute = navController.currentDestination?.rootLevelRoute() @@ -77,6 +82,7 @@ fun RootNavScreen( when (state) { RootNavState.Auth -> navController.navigateToAuthGraph(rootNavOptions) RootNavState.Splash -> navController.navigateToSplash(rootNavOptions) + RootNavState.VaultLocked -> navController.navigateToVaultUnlock(rootNavOptions) RootNavState.VaultUnlocked -> navController.navigateToVaultUnlockedGraph(rootNavOptions) } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt index a5b03d2819..d7b3d64637 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModel.kt @@ -1,10 +1,9 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.AuthState +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -20,44 +19,33 @@ private const val KEY_NAV_DESTINATION = "nav_state" */ @HiltViewModel class RootNavViewModel @Inject constructor( - private val authRepository: AuthRepository, - private val savedStateHandle: SavedStateHandle, + authRepository: AuthRepository, ) : BaseViewModel( initialState = RootNavState.Splash, ) { - - private var savedRootNavState: RootNavState? - get() = savedStateHandle[KEY_NAV_DESTINATION] - set(value) { - savedStateHandle[KEY_NAV_DESTINATION] = value - } - init { - savedRootNavState?.let { savedState: RootNavState -> - mutableStateFlow.update { savedState } - } - // Every time the nav state changes, update saved state handle: - stateFlow - .onEach { savedRootNavState = it } - .launchIn(viewModelScope) authRepository - .authStateFlow - .onEach { trySendAction(RootNavAction.AuthStateUpdated(it)) } + .userStateFlow + .onEach { sendAction(RootNavAction.Internal.UserStateUpdateReceive(it)) } .launchIn(viewModelScope) } override fun handleAction(action: RootNavAction) { when (action) { - is RootNavAction.AuthStateUpdated -> handleAuthStateUpdated(action) + is RootNavAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) } } - private fun handleAuthStateUpdated(action: RootNavAction.AuthStateUpdated) { - when (action.newState) { - is AuthState.Authenticated -> mutableStateFlow.update { RootNavState.VaultUnlocked } - is AuthState.Unauthenticated -> mutableStateFlow.update { RootNavState.Auth } - is AuthState.Uninitialized -> mutableStateFlow.update { RootNavState.Splash } + private fun handleUserStateUpdateReceive( + action: RootNavAction.Internal.UserStateUpdateReceive, + ) { + val userState = action.userState + val updatedRootNavState = when { + userState == null -> RootNavState.Auth + userState.activeAccount.isVaultUnlocked -> RootNavState.VaultUnlocked + else -> RootNavState.VaultLocked } + mutableStateFlow.update { updatedRootNavState } } } @@ -77,6 +65,12 @@ sealed class RootNavState : Parcelable { @Parcelize data object Splash : RootNavState() + /** + * App should show vault locked nav graph. + */ + @Parcelize + data object VaultLocked : RootNavState() + /** * App should show vault unlocked nav graph. */ @@ -90,7 +84,13 @@ sealed class RootNavState : Parcelable { sealed class RootNavAction { /** - * Auth state in the repository layer changed. + * Internal ViewModel actions. */ - data class AuthStateUpdated(val newState: AuthState) : RootNavAction() + sealed class Internal { + + /** + * User state in the repository layer changed. + */ + data class UserStateUpdateReceive(val userState: UserState?) : RootNavAction() + } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 9f25c51dc4..f2564985bc 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -3,9 +3,10 @@ package com.x8bit.bitwarden.ui.vault.feature.vault import android.os.Parcelable import androidx.annotation.DrawableRes 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.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.VaultData @@ -16,9 +17,10 @@ import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.hexToColor import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary import com.x8bit.bitwarden.ui.vault.feature.vault.util.toViewState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -27,29 +29,29 @@ import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import javax.inject.Inject -private const val KEY_STATE = "state" - /** * Manages [VaultState], handles [VaultAction], and launches [VaultEvent] for the [VaultScreen]. */ @Suppress("TooManyFunctions") @HiltViewModel class VaultViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, + authRepository: AuthRepository, vaultRepository: VaultRepository, ) : BaseViewModel( - initialState = savedStateHandle[KEY_STATE] ?: VaultState( - initials = activeAccountSummary.initials, - avatarColorString = activeAccountSummary.avatarColorHex, - accountSummaries = accountSummaries, - viewState = VaultState.ViewState.Loading, - ), + initialState = run { + val userState = requireNotNull(authRepository.userStateFlow.value) + val accountSummaries = userState.toAccountSummaries() + val activeAccountSummary = userState.toActiveAccountSummary() + VaultState( + initials = activeAccountSummary.initials, + avatarColorString = activeAccountSummary.avatarColorHex, + accountSummaries = accountSummaries, + viewState = VaultState.ViewState.Loading, + ) + }, ) { init { - stateFlow - .onEach { savedStateHandle[KEY_STATE] = it } - .launchIn(viewModelScope) vaultRepository .vaultDataStateFlow .onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) } @@ -66,6 +68,13 @@ class VaultViewModel @Inject constructor( ) } } + + authRepository + .userStateFlow + .onEach { + sendAction(VaultAction.Internal.UserStateUpdateReceive(userState = it)) + } + .launchIn(viewModelScope) } override fun handleAction(action: VaultAction) { @@ -81,6 +90,7 @@ class VaultViewModel @Inject constructor( is VaultAction.SecureNoteGroupClick -> handleSecureNoteClick() is VaultAction.TrashClick -> handleTrashClick() is VaultAction.VaultItemClick -> handleVaultItemClick(action) + is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action) is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) } } @@ -144,6 +154,22 @@ class VaultViewModel @Inject constructor( sendEvent(VaultEvent.NavigateToVaultItem(action.vaultItem.id)) } + private fun handleUserStateUpdateReceive(action: VaultAction.Internal.UserStateUpdateReceive) { + // Leave the current data alone if there is no UserState; we are in the process of logging + // out. + val userState = action.userState ?: return + + mutableStateFlow.update { + val accountSummaries = userState.toAccountSummaries() + val activeAccountSummary = userState.toActiveAccountSummary() + it.copy( + initials = activeAccountSummary.initials, + avatarColorString = activeAccountSummary.avatarColorHex, + accountSummaries = accountSummaries, + ) + } + } + private fun handleVaultDataReceive(action: VaultAction.Internal.VaultDataReceive) { when (val vaultData = action.vaultData) { is DataState.Error -> vaultErrorReceive(vaultData = vaultData) @@ -182,34 +208,6 @@ class VaultViewModel @Inject constructor( //endregion VaultAction Handlers } -// TODO: Get data from repository (BIT-205) -private val accountSummaries = persistentListOf( - AccountSummary( - userId = "lockedUserId", - name = "Locked User", - email = "locked@bitwarden.com", - avatarColorHex = "#00aaaa", - status = AccountSummary.Status.LOCKED, - ), - AccountSummary( - userId = "activeUserId", - name = "Active User", - email = "active@bitwarden.com", - avatarColorHex = "#aa00aa", - status = AccountSummary.Status.ACTIVE, - ), - AccountSummary( - userId = "unlockedUserId", - name = "Unlocked User", - email = "unlocked@bitwarden.com", - avatarColorHex = "#aaaa00", - status = AccountSummary.Status.UNLOCKED, - ), -) - -private val activeAccountSummary = accountSummaries - .first { it.status == AccountSummary.Status.ACTIVE } - /** * Represents the overall state for the [VaultScreen]. * @@ -541,6 +539,13 @@ sealed class VaultAction { */ sealed class Internal : VaultAction() { + /** + * Indicates a change in user state has been received. + */ + data class UserStateUpdateReceive( + val userState: UserState?, + ) : Internal() + /** * Indicates a vault data was received. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt new file mode 100644 index 0000000000..5c85b2c1bd --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensions.kt @@ -0,0 +1,43 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary + +/** + * Converts the given [UserState] to a list of [AccountSummary]. + */ +fun UserState.toAccountSummaries(): List = + accounts.map { account -> + account.toAccountSummary( + isActive = this.activeUserId == account.userId, + ) + } + +/** + * Converts the given [UserState] to an [AccountSummary] with a [AccountSummary.status] of + * [AccountSummary.Status.ACTIVE]. + */ +fun UserState.toActiveAccountSummary(): AccountSummary = + this + .activeAccount + .toAccountSummary(isActive = true) + +/** + * Converts the given [UserState.Account] to an [AccountSummary] with the correct + * [AccountSummary.Status]. The status will take into account whether or not the given account + * [isActive]. + */ +fun UserState.Account.toAccountSummary( + isActive: Boolean, +): AccountSummary = + AccountSummary( + userId = this.userId, + name = this.name, + email = this.email, + avatarColorHex = this.avatarColorHex, + status = when { + isActive -> AccountSummary.Status.ACTIVE + this.isVaultUnlocked -> AccountSummary.Status.UNLOCKED + else -> AccountSummary.Status.LOCKED + }, + ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt index 7ad761dc9e..e827bddf17 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/AuthRepositoryTest.kt @@ -37,6 +37,7 @@ import com.x8bit.bitwarden.data.platform.repository.util.FakeEnvironmentReposito import com.x8bit.bitwarden.data.platform.util.asFailure import com.x8bit.bitwarden.data.platform.util.asSuccess import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import io.mockk.clearMocks import io.mockk.coEvery @@ -47,6 +48,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.runs import io.mockk.unmockkStatic +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -62,7 +64,10 @@ class AuthRepositoryTest { private val accountsService: AccountsService = mockk() private val identityService: IdentityService = mockk() private val haveIBeenPwnedService: HaveIBeenPwnedService = mockk() - private val vaultRepository: VaultRepository = mockk() + private val mutableVaultStateFlow = MutableStateFlow(VAULT_STATE) + private val vaultRepository: VaultRepository = mockk() { + every { vaultStateFlow } returns mutableVaultStateFlow + } private val fakeAuthDiskSource = FakeAuthDiskSource() private val fakeEnvironmentRepository = FakeEnvironmentRepository() @@ -117,6 +122,41 @@ class AuthRepositoryTest { unmockkStatic(GET_TOKEN_RESPONSE_EXTENSIONS_PATH) } + @Test + fun `userStateFlow should update with changes to the UserStateJson and VaultState data`() { + fakeAuthDiskSource.userState = null + assertEquals( + null, + repository.userStateFlow.value, + ) + + mutableVaultStateFlow.value = VAULT_STATE + fakeAuthDiskSource.userState = SINGLE_USER_STATE_1 + assertEquals( + SINGLE_USER_STATE_1.toUserState( + vaultState = VAULT_STATE, + ), + repository.userStateFlow.value, + ) + + fakeAuthDiskSource.userState = MULTI_USER_STATE + assertEquals( + MULTI_USER_STATE.toUserState( + vaultState = VAULT_STATE, + ), + repository.userStateFlow.value, + ) + + val emptyVaultState = VaultState(unlockedVaultUserIds = emptySet()) + mutableVaultStateFlow.value = emptyVaultState + assertEquals( + MULTI_USER_STATE.toUserState( + vaultState = emptyVaultState, + ), + repository.userStateFlow.value, + ) + } + @Test fun `rememberedEmailAddress should pull from and update AuthDiskSource`() { // AuthDiskSource and the repository start with the same value. @@ -287,6 +327,7 @@ class AuthRepositoryTest { .returns(Result.success(successResponse)) coEvery { vaultRepository.unlockVault( + userId = USER_ID_1, email = EMAIL, kdf = ACCOUNT_1.profile.toSdkParams(), userKey = successResponse.key, @@ -321,6 +362,7 @@ class AuthRepositoryTest { captchaToken = null, ) vaultRepository.unlockVault( + userId = USER_ID_1, email = EMAIL, kdf = ACCOUNT_1.profile.toSdkParams(), userKey = successResponse.key, @@ -710,6 +752,7 @@ class AuthRepositoryTest { } returns Result.success(successResponse) coEvery { vaultRepository.unlockVault( + userId = USER_ID_1, email = EMAIL, kdf = ACCOUNT_1.profile.toSdkParams(), userKey = successResponse.key, @@ -770,6 +813,7 @@ class AuthRepositoryTest { } returns Result.success(successResponse) coEvery { vaultRepository.unlockVault( + userId = USER_ID_1, email = EMAIL, kdf = ACCOUNT_1.profile.toSdkParams(), userKey = successResponse.key, @@ -934,5 +978,8 @@ class AuthRepositoryTest { USER_ID_2 to ACCOUNT_2, ), ) + private val VAULT_STATE = VaultState( + unlockedVaultUserIds = setOf(USER_ID_1), + ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt index 938d803fc2..ad58c1863f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/auth/repository/util/UserStateJsonExtensionsTest.kt @@ -3,6 +3,8 @@ package com.x8bit.bitwarden.data.auth.repository.util import com.x8bit.bitwarden.data.auth.datasource.disk.model.AccountJson import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson import com.x8bit.bitwarden.data.auth.datasource.network.model.KdfTypeJson +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.data.vault.repository.model.VaultState import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertEquals @@ -82,4 +84,81 @@ class UserStateJsonExtensionsTest { ), ) } + + @Test + fun `toUserState should return the correct UserState for an unlocked vault`() { + assertEquals( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "activeName", + email = "activeEmail", + avatarColorHex = "activeAvatarColorHex", + isVaultUnlocked = true, + ), + ), + ), + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to AccountJson( + profile = mockk() { + every { userId } returns "activeUserId" + every { name } returns "activeName" + every { email } returns "activeEmail" + every { avatarColorHex } returns "activeAvatarColorHex" + }, + tokens = mockk(), + settings = mockk(), + ), + ), + ) + .toUserState( + vaultState = VaultState( + unlockedVaultUserIds = setOf("activeUserId"), + ), + ), + ) + } + + @Test + fun `toUserState return the correct UserState for a locked vault`() { + assertEquals( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "activeName", + email = "activeEmail", + avatarColorHex = "activeAvatarColorHex", + isVaultUnlocked = false, + ), + ), + ), + UserStateJson( + activeUserId = "activeUserId", + accounts = mapOf( + "activeUserId" to AccountJson( + profile = mockk() { + every { userId } returns "activeUserId" + every { name } returns "activeName" + every { email } returns "activeEmail" + every { avatarColorHex } returns "activeAvatarColorHex" + }, + tokens = mockk(), + settings = mockk(), + ), + ), + + ) + .toUserState( + vaultState = VaultState( + unlockedVaultUserIds = emptySet(), + ), + ), + ) + } } diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt index 85bff92290..5e3eb9e55d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/repository/VaultRepositoryTest.kt @@ -27,6 +27,7 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkSend import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.data.vault.repository.model.VaultState import com.x8bit.bitwarden.data.vault.repository.model.VaultUnlockResult import io.mockk.awaits import io.mockk.coEvery @@ -498,6 +499,12 @@ class VaultRepositoryTest { ), ) } returns Result.success(InitializeCryptoResult.Success) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) val result = vaultRepository.unlockVaultAndSyncForCurrentUser( masterPassword = "mockPassword-1", @@ -507,6 +514,12 @@ class VaultRepositoryTest { VaultUnlockResult.Success, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = setOf("mockId-1"), + ), + vaultRepository.vaultStateFlow.value, + ) coVerify { syncService.sync() } } @@ -638,6 +651,12 @@ class VaultRepositoryTest { ), ) } returns Result.failure(IllegalStateException()) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) val result = vaultRepository.unlockVaultAndSyncForCurrentUser( masterPassword = "mockPassword-1", @@ -647,6 +666,12 @@ class VaultRepositoryTest { VaultUnlockResult.GenericError, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) } @Suppress("MaxLineLength") @@ -681,12 +706,24 @@ class VaultRepositoryTest { ), ) } returns Result.success(InitializeCryptoResult.AuthenticationError) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "") assertEquals( VaultUnlockResult.AuthenticationError, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) } @Suppress("MaxLineLength") @@ -694,6 +731,12 @@ class VaultRepositoryTest { fun `unlockVaultAndSyncForCurrentUser with missing user state should return InvalidStateError `() = runTest { fakeAuthDiskSource.userState = null + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "") @@ -701,12 +744,25 @@ class VaultRepositoryTest { VaultUnlockResult.InvalidStateError, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) } @Suppress("MaxLineLength") @Test fun `unlockVaultAndSyncForCurrentUser with missing user key should return InvalidStateError `() = runTest { + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) + val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "") fakeAuthDiskSource.storeUserKey( userId = "mockId-1", @@ -721,12 +777,24 @@ class VaultRepositoryTest { VaultUnlockResult.InvalidStateError, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) } @Suppress("MaxLineLength") @Test fun `unlockVaultAndSyncForCurrentUser with missing private key should return InvalidStateError `() = runTest { + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) val result = vaultRepository.unlockVaultAndSyncForCurrentUser(masterPassword = "") fakeAuthDiskSource.storeUserKey( userId = "mockId-1", @@ -741,10 +809,17 @@ class VaultRepositoryTest { VaultUnlockResult.InvalidStateError, result, ) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) } @Test fun `unlockVault with initializeCrypto success should return Success`() = runTest { + val userId = "userId" val kdf = MOCK_PROFILE.toSdkParams() val email = MOCK_PROFILE.email val masterPassword = "drowssap" @@ -763,7 +838,15 @@ class VaultRepositoryTest { ), ) } returns InitializeCryptoResult.Success.asSuccess() + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) + val result = vaultRepository.unlockVault( + userId = userId, masterPassword = masterPassword, kdf = kdf, email = email, @@ -771,7 +854,14 @@ class VaultRepositoryTest { privateKey = privateKey, organizationalKeys = organizationalKeys, ) + assertEquals(VaultUnlockResult.Success, result) + assertEquals( + VaultState( + unlockedVaultUserIds = setOf(userId), + ), + vaultRepository.vaultStateFlow.value, + ) coVerify(exactly = 1) { vaultSdkSource.initializeCrypto( request = InitCryptoRequest( @@ -790,6 +880,7 @@ class VaultRepositoryTest { @Test fun `unlockVault with initializeCrypto authentication failure should return AuthenticationError`() = runTest { + val userId = "userId" val kdf = MOCK_PROFILE.toSdkParams() val email = MOCK_PROFILE.email val masterPassword = "drowssap" @@ -808,7 +899,15 @@ class VaultRepositoryTest { ), ) } returns InitializeCryptoResult.AuthenticationError.asSuccess() + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) + val result = vaultRepository.unlockVault( + userId = userId, masterPassword = masterPassword, kdf = kdf, email = email, @@ -816,7 +915,14 @@ class VaultRepositoryTest { privateKey = privateKey, organizationalKeys = organizationalKeys, ) + assertEquals(VaultUnlockResult.AuthenticationError, result) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) coVerify(exactly = 1) { vaultSdkSource.initializeCrypto( request = InitCryptoRequest( @@ -833,6 +939,7 @@ class VaultRepositoryTest { @Test fun `unlockVault with initializeCrypto failure should return GenericError`() = runTest { + val userId = "userId" val kdf = MOCK_PROFILE.toSdkParams() val email = MOCK_PROFILE.email val masterPassword = "drowssap" @@ -851,7 +958,15 @@ class VaultRepositoryTest { ), ) } returns Throwable("Fail").asFailure() + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) + val result = vaultRepository.unlockVault( + userId = userId, masterPassword = masterPassword, kdf = kdf, email = email, @@ -859,7 +974,14 @@ class VaultRepositoryTest { privateKey = privateKey, organizationalKeys = organizationalKeys, ) + assertEquals(VaultUnlockResult.GenericError, result) + assertEquals( + VaultState( + unlockedVaultUserIds = emptySet(), + ), + vaultRepository.vaultStateFlow.value, + ) coVerify(exactly = 1) { vaultSdkSource.initializeCrypto( request = InitCryptoRequest( @@ -876,6 +998,7 @@ class VaultRepositoryTest { @Test fun `unlockVault with initializeCrypto awaiting should block calls to sync`() = runTest { + val userId = "userId" val kdf = MOCK_PROFILE.toSdkParams() val email = MOCK_PROFILE.email val masterPassword = "drowssap" @@ -898,6 +1021,7 @@ class VaultRepositoryTest { val scope = CoroutineScope(Dispatchers.Unconfined) scope.launch { vaultRepository.unlockVault( + userId = userId, masterPassword = masterPassword, kdf = kdf, email = email, diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index e39bf7afc7..1a4738b0ee 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -4,6 +4,8 @@ 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.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState 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 @@ -16,6 +18,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -23,6 +26,9 @@ import org.junit.jupiter.api.Test class VaultUnlockViewModelTest : BaseViewModelTest() { private val environmentRepository = FakeEnvironmentRepository() + private val authRepository = mockk() { + every { userStateFlow } returns MutableStateFlow(DEFAULT_USER_STATE) + } private val vaultRepository = mockk() @Test @@ -191,17 +197,39 @@ class VaultUnlockViewModelTest : BaseViewModelTest() { vaultRepo: VaultRepository = vaultRepository, ): VaultUnlockViewModel = VaultUnlockViewModel( savedStateHandle = SavedStateHandle().apply { set("state", state) }, + authRepository = authRepository, vaultRepo = vaultRepo, environmentRepo = environmentRepo, ) } private val DEFAULT_STATE: VaultUnlockState = VaultUnlockState( - accountSummaries = emptyList(), - avatarColorString = "0000FF", - email = "bit@bitwarden.com", - initials = "BW", + accountSummaries = listOf( + AccountSummary( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + status = AccountSummary.Status.ACTIVE, + ), + ), + avatarColorString = "#aa00aa", + email = "active@bitwarden.com", + initials = "AU", dialog = null, environmentUrl = Environment.Us.label, passwordInput = "", ) + +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + isVaultUnlocked = true, + ), + ), +) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt index 1737963d6e..cd7c3c10fc 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavScreenTest.kt @@ -72,6 +72,15 @@ class RootNavScreenTest : BaseComposeTest() { } assertTrue(isSplashScreenRemoved) + // Make sure navigating to vault locked works as expected: + rootNavStateFlow.value = RootNavState.VaultLocked + composeTestRule.runOnIdle { + fakeNavHostController.assertLastNavigation( + route = "vault_unlock", + navOptions = expectedNavOptions, + ) + } + // Make sure navigating to vault unlocked works as expected: rootNavStateFlow.value = RootNavState.VaultUnlocked composeTestRule.runOnIdle { diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt index b6bbcb2b03..eb1938ef6d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/rootnav/RootNavViewModelTest.kt @@ -1,64 +1,69 @@ package com.x8bit.bitwarden.ui.platform.feature.rootnav -import androidx.lifecycle.SavedStateHandle import com.x8bit.bitwarden.data.auth.repository.AuthRepository -import com.x8bit.bitwarden.data.auth.repository.model.AuthState -import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Authenticated -import com.x8bit.bitwarden.data.auth.repository.model.AuthState.Unauthenticated +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class RootNavViewModelTest : BaseViewModelTest() { - - @Test - fun `initial state should be the state in savedStateHandle`() { - val authRepository = mockk { - every { this@mockk.authStateFlow } returns MutableStateFlow(mockk()) - } - val handle = SavedStateHandle(mapOf(("nav_state" to RootNavState.VaultUnlocked))) - val viewModel = RootNavViewModel( - authRepository = authRepository, - savedStateHandle = handle, - ) - assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value) + private val mutableUserStateFlow = MutableStateFlow(null) + private val authRepository = mockk() { + every { userStateFlow } returns mutableUserStateFlow } @Test - fun `when auth state is Uninitialized nav state should be Splash`() { - val viewModel = RootNavViewModel( - authRepository = mockk { - every { this@mockk.authStateFlow } returns MutableStateFlow(AuthState.Uninitialized) - }, - savedStateHandle = SavedStateHandle(), - ) - assertEquals(RootNavState.Splash, viewModel.stateFlow.value) - } - - @Test - fun `when auth state is Authenticated nav state should be VaultUnlocked`() { - val authRepository = mockk { - every { this@mockk.authStateFlow } returns MutableStateFlow(mockk()) - } - val viewModel = RootNavViewModel( - authRepository = authRepository, - savedStateHandle = SavedStateHandle(), - ) - assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value) - } - - @Test - fun `when auth state is Unauthenticated nav state should be Auth`() = runTest { - val viewModel = RootNavViewModel( - authRepository = mockk { - every { this@mockk.authStateFlow } returns MutableStateFlow(Unauthenticated) - }, - savedStateHandle = SavedStateHandle(), - ) + fun `when there are no accounts the nav state should be Auth`() { + mutableUserStateFlow.tryEmit(null) + val viewModel = createViewModel() assertEquals(RootNavState.Auth, viewModel.stateFlow.value) } + + @Test + fun `when the active user has an unlocked vault the nav state should be VaultUnlocked`() { + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = true, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals(RootNavState.VaultUnlocked, viewModel.stateFlow.value) + } + + @Test + fun `when the active user has a locked vault the nav state should be VaultLocked`() { + mutableUserStateFlow.tryEmit( + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = false, + ), + ), + ), + ) + val viewModel = createViewModel() + assertEquals(RootNavState.VaultLocked, viewModel.stateFlow.value) + } + + private fun createViewModel(): RootNavViewModel = + RootNavViewModel( + authRepository = authRepository, + ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index ff6591d42d..0e96170023 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -1,7 +1,8 @@ package com.x8bit.bitwarden.ui.vault.feature.vault -import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.data.auth.repository.AuthRepository +import com.x8bit.bitwarden.data.auth.repository.model.UserState import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView @@ -19,9 +20,17 @@ import org.junit.jupiter.api.Test class VaultViewModelTest : BaseViewModelTest() { + private val mutableUserStateFlow = + MutableStateFlow(DEFAULT_USER_STATE) + private val mutableVaultDataStateFlow = MutableStateFlow>(DataState.Loading) + private val authRepository: AuthRepository = + mockk { + every { userStateFlow } returns mutableUserStateFlow + } + private val vaultRepository: VaultRepository = mockk { every { vaultDataStateFlow } returns mutableVaultDataStateFlow @@ -29,19 +38,54 @@ class VaultViewModelTest : BaseViewModelTest() { } @Test - fun `initial state should be correct when not set`() { + fun `initial state should be correct`() { 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", + fun `UserState updates with a null value should do nothing`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + + mutableUserStateFlow.value = null + + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + } + + @Test + fun `UserState updates with a non-null value update the account information in the state`() { + val viewModel = createViewModel() + assertEquals(DEFAULT_STATE, viewModel.stateFlow.value) + + mutableUserStateFlow.value = DEFAULT_USER_STATE.copy( + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Other User", + email = "active@bitwarden.com", + avatarColorHex = "#00aaaa", + isVaultUnlocked = true, + ), + ), + ) + + assertEquals( + DEFAULT_STATE.copy( + avatarColorString = "#00aaaa", + initials = "OU", + accountSummaries = listOf( + AccountSummary( + userId = "activeUserId", + name = "Other User", + email = "active@bitwarden.com", + avatarColorHex = "#00aaaa", + status = AccountSummary.Status.ACTIVE, + ), + ), + ), + viewModel.stateFlow.value, ) - val viewModel = createViewModel(state = state) - assertEquals(state, viewModel.stateFlow.value) } @Test @@ -294,23 +338,41 @@ class VaultViewModelTest : BaseViewModelTest() { } } - private fun createViewModel( - state: VaultState? = DEFAULT_STATE, - ): VaultViewModel = VaultViewModel( - savedStateHandle = SavedStateHandle().apply { set("state", state) }, - vaultRepository = vaultRepository, - ) + private fun createViewModel(): VaultViewModel = + VaultViewModel( + authRepository = authRepository, + vaultRepository = vaultRepository, + ) } -private const val DEFAULT_COLOR_STRING: String = "FF0000FF" -private const val DEFAULE_INITIALS: String = "BW" private val DEFAULT_STATE: VaultState = createMockVaultState(viewState = VaultState.ViewState.Loading) +private val DEFAULT_USER_STATE = UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + isVaultUnlocked = true, + ), + ), +) + private fun createMockVaultState(viewState: VaultState.ViewState): VaultState = VaultState( - avatarColorString = DEFAULT_COLOR_STRING, - initials = DEFAULE_INITIALS, - accountSummaries = emptyList(), + avatarColorString = "#aa00aa", + initials = "AU", + accountSummaries = listOf( + AccountSummary( + userId = "activeUserId", + name = "Active User", + email = "active@bitwarden.com", + avatarColorHex = "#aa00aa", + status = AccountSummary.Status.ACTIVE, + ), + ), viewState = viewState, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt new file mode 100644 index 0000000000..c176e7105d --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/UserStateExtensionsTest.kt @@ -0,0 +1,154 @@ +package com.x8bit.bitwarden.ui.vault.feature.vault.util + +import com.x8bit.bitwarden.data.auth.repository.model.UserState +import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UserStateExtensionsTest { + @Test + fun `toAccountSummaries should return the correct list`() { + assertEquals( + listOf( + AccountSummary( + userId = "activeUserId", + name = "activeName", + email = "activeEmail", + avatarColorHex = "activeAvatarColorHex", + status = AccountSummary.Status.ACTIVE, + ), + AccountSummary( + userId = "lockedUserId", + name = "lockedName", + email = "lockedEmail", + avatarColorHex = "lockedAvatarColorHex", + status = AccountSummary.Status.LOCKED, + ), + AccountSummary( + userId = "unlockedUserId", + name = "unlockedName", + email = "unlockedEmail", + avatarColorHex = "unlockedAvatarColorHex", + status = AccountSummary.Status.UNLOCKED, + ), + ), + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "activeName", + email = "activeEmail", + avatarColorHex = "activeAvatarColorHex", + isVaultUnlocked = true, + ), + UserState.Account( + userId = "lockedUserId", + name = "lockedName", + email = "lockedEmail", + avatarColorHex = "lockedAvatarColorHex", + isVaultUnlocked = false, + ), + UserState.Account( + userId = "unlockedUserId", + name = "unlockedName", + email = "unlockedEmail", + avatarColorHex = "unlockedAvatarColorHex", + isVaultUnlocked = true, + ), + ), + ) + .toAccountSummaries(), + ) + } + + @Test + fun `toAccountSummary for an active account should return an active AccountSummary`() { + assertEquals( + AccountSummary( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + status = AccountSummary.Status.ACTIVE, + ), + UserState.Account( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = true, + ) + .toAccountSummary(isActive = true), + ) + } + + @Test + fun `toAccountSummary for an locked account should return a locked AccountSummary`() { + assertEquals( + AccountSummary( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + status = AccountSummary.Status.LOCKED, + ), + UserState.Account( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = false, + ) + .toAccountSummary(isActive = false), + ) + } + + @Test + fun `toAccountSummary for a unlocked account should return a locked AccountSummary`() { + assertEquals( + AccountSummary( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + status = AccountSummary.Status.UNLOCKED, + ), + UserState.Account( + userId = "userId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = true, + ) + .toAccountSummary(isActive = false), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `toActiveAccountSummary should return an active AccountSummary`() { + assertEquals( + AccountSummary( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + status = AccountSummary.Status.ACTIVE, + ), + UserState( + activeUserId = "activeUserId", + accounts = listOf( + UserState.Account( + userId = "activeUserId", + name = "name", + email = "email", + avatarColorHex = "avatarColorHex", + isVaultUnlocked = true, + ), + ), + ) + .toActiveAccountSummary(), + ) + } +}