PM-33266: Allow the VaultUnlockViewModel and VaultViewModel to safely initialize without a UserState

This commit is contained in:
David Perez
2026-03-06 16:16:49 -06:00
parent aa23d5e5dc
commit 674ac0721f
5 changed files with 92 additions and 16 deletions

View File

@@ -1,6 +1,7 @@
package com.x8bit.bitwarden.data.auth.repository.model
import com.bitwarden.data.repository.model.Environment
import com.bitwarden.ui.platform.base.util.toHexColorRepresentation
import com.x8bit.bitwarden.data.auth.datasource.disk.model.OnboardingStatus
import com.x8bit.bitwarden.data.platform.manager.model.FirstTimeState
@@ -43,7 +44,7 @@ data class UserState(
* @property isPremium `true` if the account has a premium membership.
* @property isLoggedIn `true` if the account is logged in, or `false` if it requires additional
* authentication to view their vault.
* @property isVaultUnlocked Whether or not the user's vault is currently unlocked.
* @property isVaultUnlocked Whether the user's vault is currently unlocked.
* @property needsPasswordReset If the user needs to reset their password.
* @property needsMasterPassword Indicates whether the user needs to create a password (e.g.
* they logged in using SSO and don't yet have one). NOTE: This should **not** be used to
@@ -96,4 +97,32 @@ data class UserState(
val hasLoginApprovingDevice: Boolean,
val hasResetPasswordPermission: Boolean,
)
@Suppress("UndocumentedPublicClass")
companion object {
/**
* A basic empty account model.
*/
val EMPTY_ACCOUNT: Account = Account(
userId = "",
name = null,
email = "",
avatarColorHex = "".toHexColorRepresentation(),
environment = Environment.Us,
isPremium = false,
isLoggedIn = false,
isVaultUnlocked = false,
needsPasswordReset = false,
organizations = emptyList(),
isBiometricsEnabled = false,
vaultUnlockType = VaultUnlockType.MASTER_PASSWORD,
needsMasterPassword = false,
hasMasterPassword = true,
trustedDevice = null,
isUsingKeyConnector = false,
onboardingStatus = OnboardingStatus.COMPLETE,
firstTimeState = FirstTimeState(),
isExportable = false,
)
}
}

View File

@@ -33,6 +33,7 @@ import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.model.UnlockType
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.emptyInputDialogMessage
import com.x8bit.bitwarden.ui.auth.feature.vaultunlock.util.unlockScreenErrorMessage
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@@ -62,10 +63,16 @@ class VaultUnlockViewModel @Inject constructor(
) : BaseViewModel<VaultUnlockState, VaultUnlockEvent, VaultUnlockAction>(
// We load the state from the savedStateHandle for testing purposes.
initialState = savedStateHandle[KEY_STATE] ?: run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val activeAccount = userState.activeAccount
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
val userState = authRepository.userStateFlow.value
val activeAccount = userState?.activeAccount ?: run {
// We use this empty account to avoid a crash that can occur during a race condition.
// The state-based navigation brought us here but the UserState has now been set to
// null, we just need to wait here a short while longer and state-based navigation will
// get us out of here.
UserState.EMPTY_ACCOUNT
}
val accountSummaries = userState?.toAccountSummaries().orEmpty()
val activeAccountSummary = activeAccount.toAccountSummary(isActive = true)
val vaultUnlockType = activeAccount.vaultUnlockType
val hasNoMasterPassword = !activeAccount.hasMasterPassword
if (!activeAccount.hasManualUnlockMechanism) {
@@ -90,11 +97,11 @@ class VaultUnlockViewModel @Inject constructor(
environmentUrl = activeAccount.environment.label,
input = "",
isBiometricEnabled = activeAccount.isBiometricsEnabled,
isBiometricsValid = authRepository.isBiometricIntegrityValid(userState.activeUserId),
isBiometricsValid = authRepository.isBiometricIntegrityValid(activeAccount.userId),
showAccountMenu = showAccountMenu,
showBiometricInvalidatedMessage = false,
vaultUnlockType = vaultUnlockType,
userId = userState.activeUserId,
userId = activeAccount.userId,
getCredentialsRequest = specialCircumstance?.toGetCredentialsRequestOrNull(),
fido2CredentialAssertionRequest = specialCircumstance?.toFido2AssertionRequestOrNull(),
createCredentialRequest = specialCircumstance?.toCreateCredentialRequestOrNull(),

View File

@@ -59,6 +59,7 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.model.ListingItemOverflo
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummaries
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toActiveAccountSummary
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toAppBarTitle
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toSnackbarData
@@ -115,10 +116,17 @@ class VaultViewModel @Inject constructor(
snackbarRelayManager: SnackbarRelayManager<SnackbarRelay>,
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
initialState = run {
val userState = requireNotNull(authRepository.userStateFlow.value)
val accountSummaries = userState.toAccountSummaries()
val activeAccountSummary = userState.toActiveAccountSummary()
val vaultFilterData = userState.activeAccount.toVaultFilterData(
val userState = authRepository.userStateFlow.value
val accountSummaries = userState?.toAccountSummaries().orEmpty()
val activeAccount = userState?.activeAccount ?: run {
// We use this empty account to avoid a crash that can occur during a race condition.
// The state-based navigation brought us here but the UserState has now been set to
// null, we just need to wait here a short while longer and state-based navigation will
// get us out of here.
UserState.EMPTY_ACCOUNT
}
val activeAccountSummary = activeAccount.toAccountSummary(isActive = true)
val vaultFilterData = activeAccount.toVaultFilterData(
isIndividualVaultDisabled = policyManager
.getActivePolicies(type = PolicyTypeJson.PERSONAL_OWNERSHIP)
.any(),
@@ -131,11 +139,11 @@ class VaultViewModel @Inject constructor(
vaultFilterData = vaultFilterData,
viewState = VaultState.ViewState.Loading,
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
isPremium = userState.activeAccount.isPremium,
isPremium = activeAccount.isPremium,
isArchiveEnabled = featureFlagManager.getFeatureFlag(FlagKey.ArchiveItems),
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
baseIconUrl = userState.activeAccount.environment.environmentUrlData.baseIconUrl,
hasMasterPassword = userState.activeAccount.hasMasterPassword,
baseIconUrl = activeAccount.environment.environmentUrlData.baseIconUrl,
hasMasterPassword = activeAccount.hasMasterPassword,
isRefreshing = false,
showImportActionCard = false,
flightRecorderSnackBar = settingsRepository

View File

@@ -59,8 +59,8 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
every { logout(reason = any()) } just runs
every { logout(userId = any(), reason = any()) } just runs
every { switchAccount(any()) } returns SwitchAccountResult.AccountSwitched
every { getOrCreateCipher(USER_ID) } returns CIPHER
every { isBiometricIntegrityValid(userId = DEFAULT_USER_STATE.activeUserId) } returns true
every { getOrCreateCipher(userId = any()) } returns CIPHER
every { isBiometricIntegrityValid(userId = any()) } returns true
}
private val vaultRepository: VaultRepository = mockk(relaxed = true) {
every { lockVault(any(), any()) } just runs
@@ -113,6 +113,22 @@ class VaultUnlockViewModelTest : BaseViewModelTest() {
verify { authRepository.getOrCreateCipher(USER_ID) }
}
@Test
fun `initial state should be correct when UserState is not present`() {
mutableUserStateFlow.update { null }
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
accountSummaries = emptyList(),
avatarColorString = "#ff000000",
initials = "",
email = "",
userId = "",
),
viewModel.stateFlow.value,
)
}
@Test
fun `initial state should be correct when set`() {
val state = DEFAULT_STATE.copy(

View File

@@ -228,6 +228,22 @@ class VaultViewModelTest : BaseViewModelTest() {
unmockkStatic(FlightRecorderDataSet::toSnackbarData)
}
@Test
fun `initial state should be correct when UserState is not present`() {
mutableUserStateFlow.update { null }
val viewModel = createViewModel()
assertEquals(
DEFAULT_STATE.copy(
accountSummaries = emptyList(),
avatarColorString = "#ff000000",
initials = "",
showImportActionCard = false,
isPremium = false,
),
viewModel.stateFlow.value,
)
}
@Test
fun `initial state should be correct and should trigger a syncIfNecessary call`() {
val viewModel = createViewModel()