diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt index bedd3f805c..75e3d975e3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/auth/repository/model/UserState.kt @@ -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, + ) + } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt index 1fb2ac77ad..cc92fc7ef4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModel.kt @@ -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( // 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(), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index f652a381c1..282040e2cd 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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, ) : BaseViewModel( 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 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt index ff87087072..328bcbad81 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/auth/feature/vaultunlock/VaultUnlockViewModelTest.kt @@ -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( diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index bf67396d34..0b55fc25f3 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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()