From a6200e638bce4dbdda2dcf81b7b051e336cc112f Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:01:39 -0600 Subject: [PATCH] BIT-1338 Finish Verification Code Screen Implementation (#721) --- .../data/vault/manager/TotpCodeManager.kt | 28 + .../data/vault/manager/TotpCodeManagerImpl.kt | 151 ++++++ .../vault/manager/di/VaultManagerModule.kt | 16 + .../manager/model/VerificationCodeItem.kt | 28 + .../data/vault/repository/VaultRepository.kt | 7 + .../vault/repository/VaultRepositoryImpl.kt | 48 ++ .../repository/di/VaultRepositoryModule.kt | 3 + .../VerificationCodeScreen.kt | 2 - .../VerificationCodeViewModel.kt | 170 ++++--- .../util/VerificationCodeExtensions.kt | 55 -- .../data/vault/manager/TotpCodeManagerTest.kt | 128 +++++ .../vault/repository/VaultRepositoryTest.kt | 118 ++++- .../VerificationCodeScreenTest.kt | 2 +- .../VerificationCodeViewModelTest.kt | 479 +++++++----------- .../util/VerificationCodeDataUtil.kt | 17 + 15 files changed, 824 insertions(+), 428 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/VerificationCodeItem.kt delete mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeDataUtil.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManager.kt new file mode 100644 index 0000000000..61c1bf8031 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManager.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.CipherView +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import kotlinx.coroutines.flow.StateFlow + +/** + * Manages the flows for getting verification codes. + */ +interface TotpCodeManager { + + /** + * Flow for getting a DataState with multiple verification code items. + */ + fun getTotpCodesStateFlow( + userId: String, + cipherList: List, + ): StateFlow>> + + /** + * Flow for getting a DataState with a single verification code item. + */ + fun getTotpCodeStateFlow( + userId: String, + cipher: CipherView, + ): StateFlow> +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerImpl.kt new file mode 100644 index 0000000000..e28ad5fd33 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerImpl.kt @@ -0,0 +1,151 @@ +package com.x8bit.bitwarden.data.vault.manager + +import com.bitwarden.core.CipherView +import com.bitwarden.core.DateTime +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import java.time.Clock + +private const val ONE_SECOND_MILLISECOND = 1000L + +/** + * Primary implementation of [TotpCodeManager]. + */ +class TotpCodeManagerImpl( + private val vaultSdkSource: VaultSdkSource, + private val dispatcherManager: DispatcherManager, + private val clock: Clock, +) : TotpCodeManager { + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + private val mutableVerificationCodeStateFlowMap = + mutableMapOf>>() + + override fun getTotpCodesStateFlow( + userId: String, + cipherList: List, + ): StateFlow>> { + // Generate state flows + val stateFlows = cipherList.map { cipherView -> + getTotpCodeStateFlowInternal(userId, cipherView) + } + return combine(stateFlows) { results -> + when { + results.any { it is DataState.Loading } -> { + DataState.Loading + } + + else -> { + DataState.Loaded( + data = results.mapNotNull { (it as DataState.Loaded).data }, + ) + } + } + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(), + initialValue = DataState.Loading, + ) + } + + override fun getTotpCodeStateFlow( + userId: String, + cipher: CipherView, + ): StateFlow> = + getTotpCodeStateFlowInternal( + userId = userId, + cipher = cipher, + ) + + @Suppress("LongMethod") + private fun getTotpCodeStateFlowInternal( + userId: String, + cipher: CipherView, + ): StateFlow> { + val cipherId = cipher.id ?: return MutableStateFlow(DataState.Loaded(null)) + + return mutableVerificationCodeStateFlowMap.getOrPut(cipherId) { + flow> { + + val totpCode = cipher + .login + ?.totp + ?: run { + emit(DataState.Loaded(null)) + return@flow + } + + var item: VerificationCodeItem? = null + while (currentCoroutineContext().isActive) { + val time = (clock.millis() / ONE_SECOND_MILLISECOND).toInt() + val dateTime = DateTime.now() + if (item == null || item.isExpired(clock = clock)) { + vaultSdkSource + .generateTotp( + totp = totpCode, + userId = userId, + time = dateTime, + ) + .onSuccess { response -> + item = VerificationCodeItem( + code = response.code, + totpCode = totpCode, + periodSeconds = response.period.toInt(), + timeLeftSeconds = response.period.toInt() - + time % response.period.toInt(), + issueTime = clock.millis(), + uriLoginViewList = cipher.login?.uris, + id = cipherId, + name = cipher.name, + username = cipher.login?.username, + ) + } + .onFailure { + emit(DataState.Loaded(null)) + return@flow + } + } else { + item?.let { + item = it.copy( + timeLeftSeconds = it.periodSeconds - + (time % it.periodSeconds), + ) + } + } + + item?.let { + emit(DataState.Loaded(it)) + } + delay(ONE_SECOND_MILLISECOND) + } + } + .onCompletion { + mutableVerificationCodeStateFlowMap.remove(cipherId) + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(), + initialValue = DataState.Loading, + ) + } + } +} + +private fun VerificationCodeItem.isExpired(clock: Clock): Boolean { + val timeExpired = issueTime + (timeLeftSeconds * ONE_SECOND_MILLISECOND) + return timeExpired < clock.millis() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt index 227bacac6a..1ea8810fc8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/di/VaultManagerModule.kt @@ -9,6 +9,8 @@ import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource import com.x8bit.bitwarden.data.vault.manager.FileManager import com.x8bit.bitwarden.data.vault.manager.FileManagerImpl +import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager +import com.x8bit.bitwarden.data.vault.manager.TotpCodeManagerImpl import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManagerImpl import dagger.Module @@ -16,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import java.time.Clock import javax.inject.Singleton /** @@ -49,4 +52,17 @@ object VaultManagerModule { userLogoutManager = userLogoutManager, dispatcherManager = dispatcherManager, ) + + @Provides + @Singleton + fun provideTotpManager( + vaultSdkSource: VaultSdkSource, + dispatcherManager: DispatcherManager, + clock: Clock, + ): TotpCodeManager = + TotpCodeManagerImpl( + vaultSdkSource = vaultSdkSource, + dispatcherManager = dispatcherManager, + clock = clock, + ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/VerificationCodeItem.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/VerificationCodeItem.kt new file mode 100644 index 0000000000..ab70db8ba4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/manager/model/VerificationCodeItem.kt @@ -0,0 +1,28 @@ +package com.x8bit.bitwarden.data.vault.manager.model + +import com.bitwarden.core.LoginUriView + +/** + * Models the items returned by the TotpCodeManager. + * + * @property code The verification code for the item. + * @property totpCode The totp code for the item. + * @property periodSeconds The time span where the code is valid in seconds. + * @property timeLeftSeconds The seconds remaining until a new code is required. + * @property issueTime The time the verification code was issued. + * @property uriLoginViewList The [LoginUriView] for the login item. + * @property id The cipher id of the item. + * @property name The name of the cipher item. + * @property username The username associated with the item. + */ +data class VerificationCodeItem( + val code: String, + val totpCode: String, + val periodSeconds: Int, + val timeLeftSeconds: Int, + val issueTime: Long, + val uriLoginViewList: List?, + val id: String, + val name: String, + val username: String?, +) 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 a254bcda3d..79ffb4a302 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 @@ -10,6 +10,7 @@ import com.bitwarden.core.SendView import com.bitwarden.crypto.Kdf import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult @@ -116,6 +117,12 @@ interface VaultRepository : VaultLockManager { */ fun getSendStateFlow(sendId: String): StateFlow> + /** + * Flow that represents the data for the TOTP verification codes for ciphers items. + * This may emit an empty list if any issues arise during code generation. + */ + fun getAuthCodesFlow(): StateFlow>> + /** * Emits the totp code result flow to listeners. */ 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 2b50e6bc80..3d9b07cebd 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 @@ -1,6 +1,7 @@ package com.x8bit.bitwarden.data.vault.repository import android.net.Uri +import com.bitwarden.core.CipherType import com.bitwarden.core.CipherView import com.bitwarden.core.CollectionView import com.bitwarden.core.DateTime @@ -33,7 +34,9 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService 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.manager.FileManager +import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult @@ -56,7 +59,9 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSend import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -65,6 +70,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -93,6 +99,7 @@ class VaultRepositoryImpl( private val authDiskSource: AuthDiskSource, private val fileManager: FileManager, private val vaultLockManager: VaultLockManager, + private val totpCodeManager: TotpCodeManager, dispatcherManager: DispatcherManager, ) : VaultRepository, VaultLockManager by vaultLockManager { @@ -293,6 +300,47 @@ class VaultRepositoryImpl( initialValue = DataState.Loading, ) + @OptIn(ExperimentalCoroutinesApi::class) + override fun getAuthCodesFlow(): StateFlow>> { + val userId = requireNotNull(activeUserId) + return vaultDataStateFlow + .map { dataState -> + dataState.map { vaultData -> + vaultData + .cipherViewList + .filter { + it.type == CipherType.LOGIN && + !it.login?.totp.isNullOrBlank() && + it.deletedDate == null + } + .toFilteredList(vaultFilterType) + } + } + .flatMapLatest { cipherDataState -> + val cipherList = cipherDataState.data ?: emptyList() + totpCodeManager + .getTotpCodesStateFlow( + userId = userId, + cipherList = cipherList, + ) + .map { verificationCodeDataStates -> + combineDataStates( + verificationCodeDataStates, + cipherDataState, + ) { verificationCodeItems, _ -> + // Just return the verification items; we are only combining the + // DataStates to know the overall state. + verificationCodeItems + } + } + } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.WhileSubscribed(), + initialValue = DataState.Loading, + ) + } + override fun emitTotpCodeResult(totpCodeResult: TotpCodeResult) { mutableTotpCodeResultFlow.tryEmit(totpCodeResult) } diff --git a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt index f034f51613..11432a38da 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/vault/repository/di/VaultRepositoryModule.kt @@ -8,6 +8,7 @@ import com.x8bit.bitwarden.data.vault.datasource.network.service.SendsService 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.manager.FileManager +import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepositoryImpl @@ -36,6 +37,7 @@ object VaultRepositoryModule { fileManager: FileManager, vaultLockManager: VaultLockManager, dispatcherManager: DispatcherManager, + totpCodeManager: TotpCodeManager, ): VaultRepository = VaultRepositoryImpl( syncService = syncService, sendsService = sendsService, @@ -46,5 +48,6 @@ object VaultRepositoryModule { fileManager = fileManager, vaultLockManager = vaultLockManager, dispatcherManager = dispatcherManager, + totpCodeManager = totpCodeManager, ) } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt index 305db5db3b..3f3b708928 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreen.kt @@ -134,8 +134,6 @@ fun VerificationCodeScreen( is VerificationCodeState.ViewState.Loading -> { BitwardenLoadingContent(modifier = modifier) } - - is VerificationCodeState.ViewState.NoItems -> Unit } } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt index f82f2c50b6..54ecb587b8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModel.kt @@ -2,23 +2,21 @@ package com.x8bit.bitwarden.ui.vault.feature.verificationcode import android.os.Parcelable import androidx.lifecycle.viewModelScope -import com.bitwarden.core.CipherType import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.platform.manager.clipboard.BitwardenClipboardManager import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.platform.repository.SettingsRepository import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.VaultRepository -import com.x8bit.bitwarden.data.vault.repository.model.VaultData import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.components.model.IconData import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType -import com.x8bit.bitwarden.ui.vault.feature.vault.util.toFilteredList -import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.toVerificationCodeViewState +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -65,8 +63,12 @@ class VerificationCodeViewModel @Inject constructor( .launchIn(viewModelScope) vaultRepository - .vaultDataStateFlow - .onEach { sendAction(VerificationCodeAction.Internal.VaultDataReceive(vaultData = it)) } + .getAuthCodesFlow() + .onEach { + sendAction( + VerificationCodeAction.Internal.AuthCodesReceive(it), + ) + } .launchIn(viewModelScope) } @@ -135,16 +137,13 @@ class VerificationCodeViewModel @Inject constructor( private fun handleInternalAction(action: VerificationCodeAction.Internal) { when (action) { is VerificationCodeAction.Internal.IconLoadingSettingReceive -> - handleIconsSettingReceived( - action, - ) + handleIconsSettingReceived(action) is VerificationCodeAction.Internal.PullToRefreshEnableReceive -> - handlePullToRefreshEnableReceive( - action, - ) + handlePullToRefreshEnableReceive(action) - is VerificationCodeAction.Internal.VaultDataReceive -> handleVaultDataReceive(action) + is VerificationCodeAction.Internal.AuthCodesReceive -> + handleAuthCodeReceive(action) } } @@ -154,10 +153,6 @@ class VerificationCodeViewModel @Inject constructor( mutableStateFlow.update { it.copy(isIconLoadingDisabled = action.isIconLoadingDisabled) } - - vaultRepository.vaultDataStateFlow.value.data?.let { vaultData -> - updateStateWithVaultData(vaultData, clearDialogState = false) - } } private fun handlePullToRefreshEnableReceive( @@ -168,24 +163,45 @@ class VerificationCodeViewModel @Inject constructor( } } - private fun handleVaultDataReceive(action: VerificationCodeAction.Internal.VaultDataReceive) { - updateViewState(action.vaultData) + private fun handleAuthCodeReceive(action: VerificationCodeAction.Internal.AuthCodesReceive) { + updateViewState(action.verificationCodeData) } - //endregion VerificationCode Handlers - private fun updateViewState(vaultData: DataState) { - when (vaultData) { - is DataState.Error -> vaultErrorReceive(vaultData) - is DataState.Loaded -> vaultLoadedReceive(vaultData = vaultData) - is DataState.Loading -> vaultLoadingReceive() - is DataState.NoNetwork -> vaultNoNetworkReceive(vaultData) - is DataState.Pending -> vaultPendingReceive(vaultData) + private fun updateViewState( + verificationCodeData: DataState>, + ) { + when (verificationCodeData) { + is DataState.Loaded -> { + vaultLoadedReceive(verificationCodeData) + } + + is DataState.Error -> { + vaultErrorReceive(verificationCodeData) + } + + is DataState.Loading -> { + vaultLoadingReceive() + } + + is DataState.NoNetwork -> { + vaultNoNetworkReceive(verificationCodeData) + } + + is DataState.Pending -> { + vaultPendingReceive(verificationCodeData) + } } } - private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork) { - if (vaultData.data != null) { - updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true) + private fun vaultNoNetworkReceive( + verificationCodeData: + DataState.NoNetwork>, + ) { + if (verificationCodeData.data != null) { + updateStateWithVerificationCodeData( + verificationCodeData = verificationCodeData.data, + clearDialogState = true, + ) } else { mutableStateFlow.update { currentState -> currentState.copy( @@ -201,12 +217,23 @@ class VerificationCodeViewModel @Inject constructor( sendEvent(VerificationCodeEvent.DismissPullToRefresh) } - private fun vaultPendingReceive(vaultData: DataState.Pending) { - updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = false) + private fun vaultPendingReceive( + verificationCodeData: DataState.Pending>, + ) { + updateStateWithVerificationCodeData( + verificationCodeData = verificationCodeData.data, + clearDialogState = false, + ) } - private fun vaultLoadedReceive(vaultData: DataState.Loaded) { - updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true) + private fun vaultLoadedReceive( + verificationCodeData: + DataState.Loaded>, + ) { + updateStateWithVerificationCodeData( + verificationCodeData = verificationCodeData.data, + clearDialogState = true, + ) sendEvent(VerificationCodeEvent.DismissPullToRefresh) } @@ -214,9 +241,12 @@ class VerificationCodeViewModel @Inject constructor( mutableStateFlow.update { it.copy(viewState = VerificationCodeState.ViewState.Loading) } } - private fun vaultErrorReceive(vaultData: DataState.Error) { + private fun vaultErrorReceive(vaultData: DataState.Error>) { if (vaultData.data != null) { - updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true) + updateStateWithVerificationCodeData( + verificationCodeData = vaultData.data, + clearDialogState = true, + ) } else { mutableStateFlow.update { it.copy( @@ -230,35 +260,40 @@ class VerificationCodeViewModel @Inject constructor( sendEvent(VerificationCodeEvent.DismissPullToRefresh) } - private fun updateStateWithVaultData( - vaultData: VaultData, + private fun updateStateWithVerificationCodeData( + verificationCodeData: List, clearDialogState: Boolean, ) { - val viewState = vaultData - .cipherViewList - .filter { - it.type == CipherType.LOGIN && - !it.login?.totp.isNullOrBlank() && - it.deletedDate == null - } - .toFilteredList(state.vaultFilterType) - .toVerificationCodeViewState( - baseIconUrl = state.baseIconUrl, - isIconLoadingDisabled = state.isIconLoadingDisabled, - ) - - if (viewState is VerificationCodeState.ViewState.NoItems) { + if (verificationCodeData.isEmpty()) { sendEvent(VerificationCodeEvent.NavigateBack) return } - mutableStateFlow.update { state -> - state.copy( - viewState = viewState, + mutableStateFlow.update { + it.copy( + viewState = VerificationCodeState.ViewState.Content( + verificationCodeDisplayItems = verificationCodeData + .map { item -> + VerificationCodeDisplayItem( + id = item.id, + authCode = item.code, + label = item.name, + supportingLabel = item.username, + periodSeconds = item.periodSeconds, + timeLeftSeconds = item.timeLeftSeconds, + startIcon = item.uriLoginViewList.toLoginIconData( + baseIconUrl = state.baseIconUrl, + isIconLoadingDisabled = state.isIconLoadingDisabled, + ), + ) + }, + ), dialogState = state.dialogState.takeUnless { clearDialogState }, ) } } + +//endregion VerificationCode Handlers } /** @@ -306,9 +341,13 @@ data class VerificationCodeState( abstract val isPullToRefreshEnabled: Boolean /** - * Represents a state where the [VerificationCodeScreen] has no items to display. + * Represents an error state for the [VerificationCodeScreen]. + * + * @property message Error message to display. */ - data object NoItems : ViewState() { + data class Error( + val message: Text, + ) : ViewState() { override val isPullToRefreshEnabled: Boolean get() = true } @@ -320,17 +359,6 @@ data class VerificationCodeState( override val isPullToRefreshEnabled: Boolean get() = false } - /** - * Represents an error state for the [VerificationCodeScreen]. - * - * @property message Error message to display. - */ - data class Error( - val message: Text, - ) : ViewState() { - override val isPullToRefreshEnabled: Boolean get() = true - } - /** * Content state for the [VerificationCodeScreen] showing the actual content or items. */ @@ -451,10 +479,10 @@ sealed class VerificationCodeAction { ) : Internal() /** - * Indicates a vault data was received. + * Indicates the verification code data was received. */ - data class VaultDataReceive( - val vaultData: DataState, + data class AuthCodesReceive( + val verificationCodeData: DataState>, ) : Internal() } } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt deleted file mode 100644 index 3f05ac42a4..0000000000 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeExtensions.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.x8bit.bitwarden.ui.vault.feature.verificationcode.util - -import com.bitwarden.core.CipherView -import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData -import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeDisplayItem -import com.x8bit.bitwarden.ui.vault.feature.verificationcode.VerificationCodeState - -/** - * Converts a list of [CipherView] to a list of [VerificationCodeDisplayItem]. - */ -fun List.toVerificationCodeViewState( - baseIconUrl: String, - isIconLoadingDisabled: Boolean, -): VerificationCodeState.ViewState = - if (isNotEmpty()) { - VerificationCodeState.ViewState.Content( - verificationCodeDisplayItems = toDisplayItemList( - baseIconUrl = baseIconUrl, - isIconLoadingDisabled = isIconLoadingDisabled, - ), - ) - } else { - VerificationCodeState.ViewState.NoItems - } - -private fun List.toDisplayItemList( - baseIconUrl: String, - isIconLoadingDisabled: Boolean, -): List = - this.map { - it.toDisplayItem( - baseIconUrl = baseIconUrl, - isIconLoadingDisabled = isIconLoadingDisabled, - ) - } - -/** - * A function used to create a sample [VerificationCodeDisplayItem]. - */ -fun CipherView.toDisplayItem( - baseIconUrl: String, - isIconLoadingDisabled: Boolean, -): VerificationCodeDisplayItem = - VerificationCodeDisplayItem( - id = id.orEmpty(), - authCode = "123456", - label = name, - supportingLabel = login?.username, - periodSeconds = 30, - timeLeftSeconds = 15, - startIcon = login?.uris.toLoginIconData( - baseIconUrl = baseIconUrl, - isIconLoadingDisabled = isIconLoadingDisabled, - ), - ) diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerTest.kt new file mode 100644 index 0000000000..db69e78e72 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/manager/TotpCodeManagerTest.kt @@ -0,0 +1,128 @@ +package com.x8bit.bitwarden.data.vault.manager + +import app.cash.turbine.test +import com.bitwarden.core.TotpResponse +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.repository.model.DataState +import com.x8bit.bitwarden.data.vault.datasource.sdk.VaultSdkSource +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem +import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset + +class TotpCodeManagerTest { + private val userId = "userId" + private val cipherList = listOf( + createMockCipherView(1, isDeleted = false), + ) + + private val vaultSdkSource: VaultSdkSource = mockk() + private val dispatcherManager: DispatcherManager = FakeDispatcherManager() + private val clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + ZoneOffset.UTC, + ) + + private val totpCodeManager: TotpCodeManager = TotpCodeManagerImpl( + vaultSdkSource = vaultSdkSource, + dispatcherManager = dispatcherManager, + clock = clock, + ) + + @Test + fun `getTotpCodeStateFlow should have loaded data with a valid values passed in`() = runTest { + val totpResponse = TotpResponse("123456", 30u) + coEvery { vaultSdkSource.generateTotp(any(), any(), any()) } returns Result.success( + totpResponse, + ) + + val expected = createVerificationCodeItem() + + totpCodeManager.getTotpCodesStateFlow(userId, cipherList).test { + assertEquals(DataState.Loaded(listOf(expected)), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getTotpCodeStateFlow should have loaded data with empty list if no totp code is provided`() = + runTest { + val totpResponse = TotpResponse("123456", 30u) + coEvery { vaultSdkSource.generateTotp(any(), any(), any()) } returns Result.success( + totpResponse, + ) + + val cipherView = createMockCipherView(1).copy( + login = createMockLoginView(1).copy( + totp = null, + ), + ) + + totpCodeManager.getTotpCodesStateFlow(userId, listOf(cipherView)).test { + assertEquals(DataState.Loaded(emptyList()), awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `getTotpCodesStateFlow should have loaded data with empty list if unable to generate auth code`() = + runTest { + coEvery { vaultSdkSource.generateTotp(any(), any(), any()) } returns + Result.failure( + exception = Exception(), + ) + + val cipherView = createMockCipherView(1).copy( + login = createMockLoginView(1).copy( + totp = null, + ), + ) + + totpCodeManager.getTotpCodesStateFlow(userId, listOf(cipherView)).test { + assertEquals(DataState.Loaded(emptyList()), awaitItem()) + } + } + + @Test + fun `getTotpCodeStateFlow should have loaded item with a valid data passed in`() = runTest { + + val totpResponse = TotpResponse("123456", 30u) + coEvery { vaultSdkSource.generateTotp(any(), any(), any()) } returns Result.success( + totpResponse, + ) + + val cipherView = createMockCipherView(1) + + val expected = createVerificationCodeItem() + + totpCodeManager.getTotpCodeStateFlow(userId, cipherView).test { + assertEquals(DataState.Loaded(expected), awaitItem()) + } + } + + @Test + fun `getTotpCodeFlow should have null data if unable to get item`() = + runTest { + val totpResponse = TotpResponse("123456", 30u) + coEvery { vaultSdkSource.generateTotp(any(), any(), any()) } returns Result.success( + totpResponse, + ) + + val cipherView = createMockCipherView(1).copy( + login = null, + ) + + totpCodeManager.getTotpCodeStateFlow(userId, cipherView).test { + assertEquals(DataState.Loaded(null), awaitItem()) + } + } +} 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 fa88153d24..aff0177008 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 @@ -55,7 +55,9 @@ import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSdkFolder 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.manager.FileManager +import com.x8bit.bitwarden.data.vault.manager.TotpCodeManager import com.x8bit.bitwarden.data.vault.manager.VaultLockManager +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult @@ -74,6 +76,7 @@ import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCipherList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkCollectionList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkFolderList import com.x8bit.bitwarden.data.vault.repository.util.toEncryptedSdkSendList +import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem import io.mockk.awaits import io.mockk.coEvery import io.mockk.coVerify @@ -104,6 +107,7 @@ class VaultRepositoryTest { private val sendsService: SendsService = mockk() private val ciphersService: CiphersService = mockk() private val vaultDiskSource: VaultDiskSource = mockk() + private val totpCodeManager: TotpCodeManager = mockk() private val vaultSdkSource: VaultSdkSource = mockk { every { clearCrypto(userId = any()) } just runs } @@ -130,6 +134,7 @@ class VaultRepositoryTest { authDiskSource = fakeAuthDiskSource, vaultLockManager = vaultLockManager, dispatcherManager = dispatcherManager, + totpCodeManager = totpCodeManager, fileManager = fileManager, ) @@ -2196,6 +2201,60 @@ class VaultRepositoryTest { ) } + @Test + fun `getVerificationCodesFlow should update data state when state changes`() = runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + val userId = "mockId-1" + + val mockSyncResponse = createMockSyncResponse(number = 1) + coEvery { syncService.sync() } returns mockSyncResponse.asSuccess() + coEvery { + vaultSdkSource.initializeOrganizationCrypto( + userId = userId, + request = InitOrgCryptoRequest( + organizationKeys = createMockOrganizationKeys(1), + ), + ) + } returns InitializeCryptoResult.Success.asSuccess() + coEvery { + vaultDiskSource.replaceVaultData( + userId = MOCK_USER_STATE.activeUserId, + vault = mockSyncResponse, + ) + } just runs + + val stateFlow = MutableStateFlow>>( + DataState.Loading, + ) + + every { + totpCodeManager.getTotpCodesStateFlow(userId = userId, any()) + } returns stateFlow + + setupDataStateFlow(userId = userId) + + vaultRepository.getAuthCodesFlow().test { + assertEquals( + DataState.Loading, + awaitItem(), + ) + + stateFlow.tryEmit(DataState.Loaded(listOf(createVerificationCodeItem()))) + + assertEquals( + DataState.Loaded(listOf(createVerificationCodeItem())), + awaitItem(), + ) + + vaultRepository.sync() + + assertEquals( + DataState.Pending(listOf(createVerificationCodeItem())), + awaitItem(), + ) + } + } + //region Helper functions /** @@ -2348,6 +2407,63 @@ class VaultRepositoryTest { } } + private suspend fun setupDataStateFlow(userId: String) { + coEvery { + vaultSdkSource.decryptCipherList( + userId = userId, + cipherList = listOf(createMockSdkCipher(1)), + ) + } returns listOf(createMockCipherView(1)).asSuccess() + coEvery { + vaultSdkSource.decryptFolderList( + userId = userId, + folderList = listOf(createMockSdkFolder(1)), + ) + } returns listOf(createMockFolderView(number = 1)).asSuccess() + coEvery { + vaultSdkSource.decryptCollectionList( + userId = userId, + collectionList = listOf(createMockSdkCollection(1)), + ) + } returns listOf(createMockCollectionView(number = 1)).asSuccess() + coEvery { + vaultSdkSource.decryptSendList( + userId = userId, + sendList = listOf(createMockSdkSend(number = 1)), + ) + } returns listOf(createMockSendView(number = 1)).asSuccess() + val ciphersFlow = bufferedMutableSharedFlow>() + val collectionsFlow = bufferedMutableSharedFlow>() + val foldersFlow = bufferedMutableSharedFlow>() + val sendsFlow = bufferedMutableSharedFlow>() + setupVaultDiskSourceFlows( + ciphersFlow = ciphersFlow, + collectionsFlow = collectionsFlow, + foldersFlow = foldersFlow, + sendsFlow = sendsFlow, + ) + + vaultRepository.vaultDataStateFlow.test { + ciphersFlow.tryEmit(listOf(createMockCipher(1))) + collectionsFlow.tryEmit(listOf(createMockCollection(number = 1))) + foldersFlow.tryEmit(listOf(createMockFolder(number = 1))) + sendsFlow.tryEmit(listOf(createMockSend(number = 1))) + assertEquals(DataState.Loading, awaitItem()) + + assertEquals( + DataState.Loaded( + data = VaultData( + cipherViewList = listOf(createMockCipherView(1)), + collectionViewList = listOf(createMockCollectionView(number = 1)), + folderViewList = listOf(createMockFolderView(number = 1)), + sendViewList = listOf(createMockSendView(number = 1)), + ), + ), + awaitItem(), + ) + } + } + private fun setupMockUri( url: String, queryParams: Map = emptyMap(), @@ -2367,7 +2483,7 @@ class VaultRepositoryTest { return mockInstant } - //endregion Helper functions +//endregion Helper functions } private val MOCK_PROFILE = AccountJson.Profile( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt index b9a6249405..72a3901774 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeScreenTest.kt @@ -107,7 +107,7 @@ class VerificationCodeScreenTest : BaseComposeTest() { .onNodeWithText(message) .assertIsNotDisplayed() - mutableStateFlow.update { it.copy(viewState = VerificationCodeState.ViewState.NoItems) } + mutableStateFlow.update { it.copy(viewState = VerificationCodeState.ViewState.Loading) } composeTestRule .onNodeWithText(message) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt index ccc1671d12..33528f681b 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/VerificationCodeViewModelTest.kt @@ -10,16 +10,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView -import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCollectionView -import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockFolderView -import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem import com.x8bit.bitwarden.data.vault.repository.VaultRepository -import com.x8bit.bitwarden.data.vault.repository.model.VaultData 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.vault.model.VaultFilterType -import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.toDisplayItem +import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData +import com.x8bit.bitwarden.ui.vault.feature.verificationcode.util.createVerificationCodeItem import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -28,6 +26,7 @@ import io.mockk.runs import io.mockk.unmockkStatic import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -39,11 +38,12 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { private val clipboardManager: BitwardenClipboardManager = mockk() - private val mutableVaultDataStateFlow = - MutableStateFlow>(DataState.Loading) + private val mutableAuthCodeFlow = + MutableStateFlow>>(DataState.Loading) + private val vaultRepository: VaultRepository = mockk { every { vaultFilterType } returns VaultFilterType.AllVaults - every { vaultDataStateFlow } returns mutableVaultDataStateFlow + every { getAuthCodesFlow() } returns mutableAuthCodeFlow.asStateFlow() every { sync() } just runs } @@ -99,22 +99,11 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } @Test - fun `on ItemClick should emit ItemClick`() = runTest { - val viewModel = createViewModel() - val testId = "testId" - - viewModel.eventFlow.test { - viewModel.trySendAction(VerificationCodeAction.ItemClick(testId)) - assertEquals(VerificationCodeEvent.NavigateToVaultItem(testId), awaitItem()) - } - } - - @Test - fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest { + fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest { val viewModel = createViewModel() viewModel.eventFlow.test { - viewModel.actionChannel.trySend(VerificationCodeAction.SearchIconClick) - assertEquals(VerificationCodeEvent.NavigateToVaultSearchScreen, awaitItem()) + viewModel.actionChannel.trySend(VerificationCodeAction.ItemClick(id = "mock")) + assertEquals(VerificationCodeEvent.NavigateToVaultItem(id = "mock"), awaitItem()) } } @@ -130,6 +119,22 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } } + @Test + fun `RefreshClick should sync`() = runTest { + val viewModel = createViewModel() + viewModel.actionChannel.trySend(VerificationCodeAction.RefreshClick) + verify { vaultRepository.sync() } + } + + @Test + fun `SearchIconClick should emit NavigateToVaultSearchScreen`() = runTest { + val viewModel = createViewModel() + viewModel.eventFlow.test { + viewModel.actionChannel.trySend(VerificationCodeAction.SearchIconClick) + assertEquals(VerificationCodeEvent.NavigateToVaultSearchScreen, awaitItem()) + } + } + @Test fun `SyncClick should display the loading dialog and call sync`() { val viewModel = createViewModel() @@ -149,52 +154,23 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } } + @Suppress("MaxLineLength") @Test - fun `ItemClick for vault item should emit NavigateToVaultItem`() = runTest { - val viewModel = createViewModel() - viewModel.eventFlow.test { - viewModel.actionChannel.trySend(VerificationCodeAction.ItemClick(id = "mock")) - assertEquals(VerificationCodeEvent.NavigateToVaultItem(id = "mock"), awaitItem()) - } - } - - @Test - fun `RefreshClick should sync`() = runTest { - val viewModel = createViewModel() - viewModel.actionChannel.trySend(VerificationCodeAction.RefreshClick) - verify { vaultRepository.sync() } - } - - @Test - fun `vaultDataStateFlow Pending with data should update state to Content`() = runTest { + fun `AuthCodeFlow Pending with data should update state to Content`() { setupMockUri() - mutableVaultDataStateFlow.tryEmit( + val viewModel = createViewModel() + + mutableAuthCodeFlow.tryEmit( value = DataState.Pending( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), + data = listOf(createVerificationCodeItem()), ), ) - val viewModel = createViewModel() - assertEquals( createVerificationCodeState( viewState = VerificationCodeState.ViewState.Content( - verificationCodeDisplayItems = listOf( - createMockCipherView( - number = 1, - isDeleted = false, - ) - .toDisplayItem( - baseIconUrl = initialState.baseIconUrl, - isIconLoadingDisabled = initialState.isIconLoadingDisabled, - ), - ), + createDisplayItemList(), ), ), viewModel.stateFlow.value, @@ -203,56 +179,90 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Pending with empty data should call NavigateBack to go to the vault screen`() = + fun `AuthCodeFlow Pending with no data should call NavigateBack to go to the vault screen`() = runTest { - val dataState = DataState.Pending( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) + setupMockUri() val viewModel = createViewModel() + mutableAuthCodeFlow.tryEmit( + value = DataState.Pending( + data = listOf(), + ), + ) + viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) } } + @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Pending with trash data should call NavigateBack event`() = runTest { - val dataState = DataState.Pending( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), + fun `AuthCodeFlow Error with data should update state to Content`() = runTest { + setupMockUri() + + val viewModel = createViewModel() + + mutableAuthCodeFlow.tryEmit( + value = DataState.Error( + data = listOf(createVerificationCodeItem()), + error = IllegalStateException(), ), ) - val viewModel = createViewModel() - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) - } - } - - @Test - fun `vaultDataStateFlow Error without data should update state to Error`() = runTest { - val dataState = DataState.Error( - error = IllegalStateException(), - ) - - val viewModel = createViewModel() - - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) } + + assertEquals( + createVerificationCodeState( + viewState = VerificationCodeState.ViewState.Content( + createDisplayItemList(), + ), + ), + viewModel.stateFlow.value, + ) + } + + @Suppress("MaxLineLength") + @Test + fun `AuthCodeFlow Error with no data should call NavigateBack to go to the vault screen`() = + runTest { + setupMockUri() + + val viewModel = createViewModel() + + mutableAuthCodeFlow.tryEmit( + value = DataState.Error( + data = listOf(), + error = IllegalStateException(), + ), + ) + + viewModel.eventFlow.test { + assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) + assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) + } + } + + @Suppress("MaxLineLength") + @Test + fun `AuthCodeFlow Error with null data should show error screen`() = runTest { + setupMockUri() + + val viewModel = createViewModel() + + mutableAuthCodeFlow.tryEmit( + value = DataState.Error( + data = null, + error = IllegalStateException(), + ), + ) + + viewModel.eventFlow.test { + assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) + } + assertEquals( createVerificationCodeState( viewState = VerificationCodeState.ViewState.Error( @@ -263,101 +273,30 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { ) } - @Test - fun `vaultDataStateFlow Error with data should update state to Content`() = runTest { - setupMockUri() - - val dataState = DataState.Error( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - error = IllegalStateException(), - ) - - val viewModel = createViewModel() - - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } - assertEquals( - createVerificationCodeState( - viewState = VerificationCodeState.ViewState.Content( - verificationCodeDisplayItems = listOf( - createMockCipherView( - number = 1, - isDeleted = false, - ) - .toDisplayItem( - baseIconUrl = initialState.baseIconUrl, - isIconLoadingDisabled = initialState.isIconLoadingDisabled, - ), - ), - ), - ), - viewModel.stateFlow.value, - ) - } - @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Error with empty data should call NavigateBack to go the vault screen`() = + fun `AuthCodeFlow NoNetwork with empty data should call NavigateBack to go to the vault screen`() = runTest { - val dataState = DataState.Error( - data = VaultData( - cipherViewList = emptyList(), - folderViewList = emptyList(), - collectionViewList = emptyList(), - sendViewList = emptyList(), - ), - error = IllegalStateException(), - ) - val viewModel = createViewModel() - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } - } - - @Suppress("MaxLineLength") - @Test - fun `vaultDataStateFlow Error with trash data should call NavigateBack to go to the vault screen`() = - runTest { - val dataState = DataState.Error( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - error = IllegalStateException(), + mutableAuthCodeFlow.tryEmit( + DataState.NoNetwork(emptyList()), ) - val viewModel = createViewModel() - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) } } @Test - fun `vaultDataStateFlow NoNetwork without data should update state to Error`() = runTest { - val dataState = DataState.NoNetwork() - + fun `AuthCodeFlow NoNetwork with null should update state to Error`() = runTest { val viewModel = createViewModel() - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } + mutableAuthCodeFlow.tryEmit( + DataState.NoNetwork(null), + ) + assertEquals( createVerificationCodeState( viewState = VerificationCodeState.ViewState.Error( @@ -371,37 +310,25 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } @Test - fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest { + fun `AuthCodeFlow NoNetwork with data should update state to Content`() = runTest { setupMockUri() - val dataState = DataState.NoNetwork( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = false)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) - val viewModel = createViewModel() + mutableAuthCodeFlow.tryEmit( + value = DataState.NoNetwork( + listOf(createVerificationCodeItem()), + ), + ) + viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) } + assertEquals( createVerificationCodeState( viewState = VerificationCodeState.ViewState.Content( - verificationCodeDisplayItems = listOf( - createMockCipherView( - number = 1, - isDeleted = false, - ) - .toDisplayItem( - baseIconUrl = initialState.baseIconUrl, - isIconLoadingDisabled = initialState.isIconLoadingDisabled, - ), - ), + createDisplayItemList(), ), ), viewModel.stateFlow.value, @@ -409,113 +336,49 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { } @Test - fun `vaultDataStateFlow NoNetwork with trash data should call NavigateBack`() = runTest { - val dataState = DataState.NoNetwork( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) + fun `AuthCodeFlow Loaded with empty data should call NavigateBack to go the vault screen`() = + runTest { + val viewModel = createViewModel() + + mutableAuthCodeFlow.tryEmit( + DataState.Loaded(emptyList()), + ) + + viewModel.eventFlow.test { + assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) + assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) + } + } + + @Test + fun `AuthCodeFlow Loaded with valid items should update ViewState to content`() = runTest { + setupMockUri() val viewModel = createViewModel() + mutableAuthCodeFlow.tryEmit( + value = DataState.Loaded( + listOf(createVerificationCodeItem()), + ), + ) + viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) } + + assertEquals( + createVerificationCodeState( + viewState = VerificationCodeState.ViewState.Content( + createDisplayItemList(), + ), + ), + viewModel.stateFlow.value, + ) } - @Suppress("MaxLineLength") @Test - fun `vaultDataStateFlow Loaded with empty items should update call NavigateBack to go the vault screen`() = - runTest { - val dataState = DataState.Loaded( - data = VaultData( - cipherViewList = emptyList(), - folderViewList = emptyList(), - collectionViewList = emptyList(), - sendViewList = emptyList(), - ), - ) - val viewModel = createViewModel() - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } - } - - @Suppress("MaxLineLength") - @Test - fun `vaultDataStateFlow Loaded with trash items should call NavigateBack to go to the vault screen`() = - runTest { - val dataState = DataState.Loaded( - data = VaultData( - cipherViewList = listOf(createMockCipherView(number = 1, isDeleted = true)), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) - val viewModel = createViewModel() - - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.NavigateBack, awaitItem()) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } - } - - @Test - fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() = - runTest { - setupMockUri() - val dataState = DataState.Loaded( - data = VaultData( - cipherViewList = listOf( - createMockCipherView( - number = 1, - isDeleted = false, - ), - ), - folderViewList = listOf(createMockFolderView(number = 1)), - collectionViewList = listOf(createMockCollectionView(number = 1)), - sendViewList = listOf(createMockSendView(number = 1)), - ), - ) - - val viewModel = createViewModel() - - viewModel.eventFlow.test { - mutableVaultDataStateFlow.tryEmit(value = dataState) - assertEquals(VerificationCodeEvent.DismissPullToRefresh, awaitItem()) - } - - assertEquals( - createVerificationCodeState( - viewState = VerificationCodeState.ViewState.Content( - listOf( - createMockCipherView( - number = 1, - isDeleted = false, - ) - .toDisplayItem( - baseIconUrl = initialState.baseIconUrl, - isIconLoadingDisabled = initialState.isIconLoadingDisabled, - ), - ), - ), - ), - viewModel.stateFlow.value, - ) - } - - @Test - fun `vaultDataStateFlow Loading should update state to Loading`() = runTest { - mutableVaultDataStateFlow.tryEmit(value = DataState.Loading) + fun `AuthCodeFlow Loading should update state to Loading`() = runTest { + mutableAuthCodeFlow.tryEmit(value = DataState.Loading) val viewModel = createViewModel() @@ -580,13 +443,33 @@ class VerificationCodeViewModelTest : BaseViewModelTest() { @Suppress("MaxLineLength") private fun createVerificationCodeState( viewState: VerificationCodeState.ViewState = VerificationCodeState.ViewState.Loading, - ): VerificationCodeState = - VerificationCodeState( - viewState = viewState, - vaultFilterType = vaultRepository.vaultFilterType, - isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, - baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, - dialogState = null, - isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, + ) = VerificationCodeState( + viewState = viewState, + vaultFilterType = vaultRepository.vaultFilterType, + isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled, + baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl, + dialogState = null, + isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value, + ) + + private fun createDisplayItemList() = listOf( + createMockCipherView( + number = 1, + isDeleted = false, ) + .let { cipherView -> + VerificationCodeDisplayItem( + id = cipherView.id.toString(), + authCode = "123456", + label = cipherView.name, + supportingLabel = cipherView.login?.username, + periodSeconds = 30, + timeLeftSeconds = 30, + startIcon = cipherView.login?.uris.toLoginIconData( + isIconLoadingDisabled = initialState.isIconLoadingDisabled, + baseIconUrl = initialState.baseIconUrl, + ), + ) + }, + ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeDataUtil.kt new file mode 100644 index 0000000000..7fbe72a868 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/verificationcode/util/VerificationCodeDataUtil.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.ui.vault.feature.verificationcode.util + +import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockLoginView +import com.x8bit.bitwarden.data.vault.manager.model.VerificationCodeItem + +fun createVerificationCodeItem() = + VerificationCodeItem( + code = "123456", + totpCode = "mockTotp-1", + periodSeconds = 30, + id = "mockId-1", + issueTime = 1698408000000, + timeLeftSeconds = 30, + name = "mockName-1", + uriLoginViewList = createMockLoginView(1).uris, + username = "mockUsername-1", + )