diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt index 258e67bc1..998bd1e2b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensions.kt @@ -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]. */ 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 800010d3a..772844ac6 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 @@ -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( diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 4936be5af..6a2db3d76 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -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, ) { diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt index 0239fc12a..162f9aa7c 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/DataStateExtensionsTest.kt @@ -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) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt index 856e0831a..205d2d78f 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModelTest.kt @@ -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?,