BIT-2273: Maintain data when combining data state (#1298)

This commit is contained in:
David Perez 2024-04-23 15:42:17 -05:00 committed by Álison Fernandes
parent 4326630d10
commit b730330196
5 changed files with 393 additions and 35 deletions

View file

@ -20,6 +20,20 @@ inline fun <T : Any?, R : Any?> DataState<T>.map(
is DataState.NoNetwork -> DataState.NoNetwork(data?.let(transform))
}
/**
* Maps the data inside a [DataState] with the given [transform] regardless of the data's
* nullability.
*/
inline fun <T : Any?, R : Any?> DataState<T>.mapNullable(
transform: (T?) -> R,
): DataState<R> = when (this) {
is DataState.Loaded -> DataState.Loaded(data = transform(data))
is DataState.Loading -> DataState.Loading
is DataState.Pending -> DataState.Pending(data = transform(data))
is DataState.Error -> DataState.Error(error = error, data = transform(data))
is DataState.NoNetwork -> DataState.NoNetwork(data = transform(data))
}
/**
* Emits all values of a [DataState] [Flow] until it emits a [DataState.Loaded].
*/

View file

@ -33,6 +33,7 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.map
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable
import com.x8bit.bitwarden.data.platform.repository.util.observeWhenSubscribedAndLoggedIn
import com.x8bit.bitwarden.data.platform.repository.util.updateToPendingOrLoading
import com.x8bit.bitwarden.data.platform.util.asFailure
@ -508,11 +509,11 @@ class VaultRepositoryImpl(
combineDataStates(
totpCodeDataState,
cipherDataState,
) { totpCodeData, _ ->
// Just return the verification items; we are only combining the
// DataStates to know the overall state.
totpCodeData
) { _, _ ->
// We are only combining the DataStates to know the overall state,
// we map it to the appropriate value below.
}
.mapNullable { totpCodeDataState.data }
}
}
.stateIn(

View file

@ -13,6 +13,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.ValidatePasswordResult
import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.combineDataStates
import com.x8bit.bitwarden.data.platform.repository.util.mapNullable
import com.x8bit.bitwarden.data.vault.manager.FileManager
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
@ -89,12 +90,16 @@ class VaultItemViewModel @Inject constructor(
vaultDataState = combineDataStates(
cipherViewState,
authCodeState,
) { vaultData, _ ->
VaultItemStateData(
cipher = vaultData,
totpCodeItemData = totpCodeData,
)
},
) { _, _ ->
// We are only combining the DataStates to know the overall state,
// we map it to the appropriate value below.
}
.mapNullable {
VaultItemStateData(
cipher = cipherViewState.data,
totpCodeItemData = totpCodeData,
)
},
)
}
.onEach(::sendAction)
@ -743,8 +748,10 @@ class VaultItemViewModel @Inject constructor(
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
viewState = vaultDataState.toViewStateOrError(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data?.totpCodeItemData,
errorText = R.string.generic_error_message.asText(),
),
)
}
@ -753,16 +760,11 @@ class VaultItemViewModel @Inject constructor(
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
.cipher
?.toViewState(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
viewState = vaultDataState.toViewStateOrError(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
errorText = R.string.generic_error_message.asText(),
),
)
}
}
@ -776,10 +778,15 @@ class VaultItemViewModel @Inject constructor(
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.internet_connection_required_title
viewState = vaultDataState.toViewStateOrError(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data?.totpCodeItemData,
errorText = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
.concat(
" ".asText(),
R.string.internet_connection_required_message.asText(),
),
),
)
}
@ -788,22 +795,27 @@ class VaultItemViewModel @Inject constructor(
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = vaultDataState
.data
.cipher
?.toViewState(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
)
?: VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
viewState = vaultDataState.toViewStateOrError(
isPremiumUser = userState.activeAccount.isPremium,
totpCodeItemData = vaultDataState.data.totpCodeItemData,
errorText = R.string.generic_error_message.asText(),
),
)
}
}
}
}
private fun DataState<VaultItemStateData>.toViewStateOrError(
isPremiumUser: Boolean,
totpCodeItemData: TotpCodeItemData?,
errorText: Text,
): VaultItemState.ViewState = this
.data
?.cipher
?.toViewState(isPremiumUser = isPremiumUser, totpCodeItemData = totpCodeItemData)
?: VaultItemState.ViewState.Error(message = errorText)
private fun handleValidatePasswordReceive(
action: VaultItemAction.Internal.ValidatePasswordReceive,
) {

View file

@ -9,6 +9,184 @@ import org.junit.jupiter.api.Test
class DataStateExtensionsTest {
@Test
fun `map on Loaded should call transform and return appropriate value for Loaded`() {
val mapValue = 5
val expected = DataState.Loaded(mapValue)
val dataState = DataState.Loaded("Loaded")
val result = dataState.map {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `map on Loading should not call transform and return Loading`() {
val dataState = DataState.Loading
val result = dataState.map {
5
}
assertEquals(dataState, result)
}
@Test
fun `map on Pending should call transform and return Pending`() {
val mapValue = 5
val expected = DataState.Pending(mapValue)
val dataState = DataState.Pending("Pending")
val result = dataState.map {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `map on Error should not call transform with null data and return Error`() {
val error = Throwable()
val dataState = DataState.Error(error, null)
val result = dataState.map {
5
}
assertEquals(dataState, result)
}
@Test
fun `map on Error should call transform with nonnull data and return Error`() {
val error = Throwable()
val mapValue = 5
val expected = DataState.Error(error, mapValue)
val dataState = DataState.Error(error, "Error")
val result = dataState.map {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `map on NoNetwork should not call transform with null data and return NoNetwork`() {
val dataState = DataState.NoNetwork(null)
val result = dataState.map {
5
}
assertEquals(dataState, result)
}
@Test
fun `map on NoNetwork should call transform with nonnull data and return NoNetwork`() {
val mapValue = 5
val expected = DataState.NoNetwork(mapValue)
val dataState = DataState.NoNetwork("NoNetwork")
val result = dataState.map {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on Loaded should call transform and return appropriate value for Loaded`() {
val mapValue = 5
val expected = DataState.Loaded(mapValue)
val dataState = DataState.Loaded("Loaded")
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on Loading should not call transform and return Loading`() {
val dataState = DataState.Loading
val result = dataState.mapNullable {
5
}
assertEquals(dataState, result)
}
@Test
fun `mapNullable on Pending should call transform and return Pending`() {
val mapValue = 5
val expected = DataState.Pending(mapValue)
val dataState = DataState.Pending("Pending")
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on Error should call transform with null data and return Error`() {
val error = Throwable()
val mapValue = 5
val expected = DataState.Error(error, mapValue)
val dataState = DataState.Error(error, null)
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on Error should call transform with nonnull data and return Error`() {
val error = Throwable()
val mapValue = 5
val expected = DataState.Error(error, mapValue)
val dataState = DataState.Error(error, "Error")
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on NoNetwork should call transform with null data and return NoNetwork`() {
val mapValue = 5
val expected = DataState.NoNetwork(mapValue)
val dataState = DataState.NoNetwork(null)
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `mapNullable on NoNetwork should call transform with nonnull data and return NoNetwork`() {
val mapValue = 5
val expected = DataState.NoNetwork(5)
val dataState = DataState.NoNetwork("NoNetwork")
val result = dataState.mapNullable {
mapValue
}
assertEquals(expected, result)
}
@Test
fun `takeUtilLoaded should complete after a Loaded state is emitted`() = runTest {
val mutableStateFlow = MutableStateFlow<DataState<Unit>>(DataState.Loading)

View file

@ -22,6 +22,7 @@ import com.x8bit.bitwarden.data.vault.repository.model.DownloadAttachmentResult
import com.x8bit.bitwarden.data.vault.repository.model.RestoreCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData
import com.x8bit.bitwarden.ui.vault.feature.item.util.createCommonContent
import com.x8bit.bitwarden.ui.vault.feature.item.util.createLoginContent
@ -1949,6 +1950,158 @@ class VaultItemViewModelTest : BaseViewModelTest() {
}
}
@Nested
inner class VaultItemFlow {
@BeforeEach
fun setup() {
mutableUserStateFlow.value = DEFAULT_USER_STATE
mutableAuthCodeItemFlow.value = DataState.Loaded(data = null)
}
@Test
fun `on VaultDataReceive with Loading should update the dialog state to loading`() {
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Loading
assertEquals(
DEFAULT_STATE.copy(viewState = VaultItemState.ViewState.Loading),
viewModel.stateFlow.value,
)
}
@Test
fun `on VaultDataReceive with Loaded and nonnull data should update the ViewState`() {
val viewState = mockk<VaultItemState.ViewState>()
val cipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true, totpCodeItemData = null)
} returns viewState
}
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Loaded(data = cipherView)
assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value)
}
@Test
fun `on VaultDataReceive with Loaded and null data should update the ViewState to Error`() {
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Loaded(data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on VaultDataReceive with Pending and nonnull data should update the ViewState`() {
val viewState = mockk<VaultItemState.ViewState>()
val cipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true, totpCodeItemData = null)
} returns viewState
}
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Pending(data = cipherView)
assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `on VaultDataReceive with Pending and null data should update the ViewState to Error`() {
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Pending(data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on VaultDataReceive with Error and nonnull data should update the ViewState`() {
val viewState = mockk<VaultItemState.ViewState>()
val cipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true, totpCodeItemData = null)
} returns viewState
}
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Error(error = Throwable(), data = cipherView)
assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value)
}
@Test
fun `on VaultDataReceive with Error and null data should update the ViewState to Error`() {
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.Error(error = Throwable(), data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `on VaultDataReceive with NoNetwork and nonnull data should update the ViewState`() {
val viewState = mockk<VaultItemState.ViewState>()
val cipherView = mockk<CipherView> {
every {
toViewState(isPremiumUser = true, totpCodeItemData = null)
} returns viewState
}
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.NoNetwork(data = cipherView)
assertEquals(DEFAULT_STATE.copy(viewState = viewState), viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `on VaultDataReceive with NoNetwork and null data should update the ViewState to Error`() {
val viewModel = createViewModel(state = null)
mutableVaultItemFlow.value = DataState.NoNetwork(data = null)
assertEquals(
DEFAULT_STATE.copy(
viewState = VaultItemState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(
" ".asText(),
R.string.internet_connection_required_message.asText(),
),
),
),
viewModel.stateFlow.value,
)
}
}
@Suppress("LongParameterList")
private fun createViewModel(
state: VaultItemState?,