BIT-1338 Finish Verification Code Screen Implementation (#721)

This commit is contained in:
Oleg Semenenko
2024-01-23 12:01:39 -06:00
committed by GitHub
parent c6f6a4c7ca
commit a6200e638b
15 changed files with 824 additions and 428 deletions

View File

@@ -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<CipherView>,
): StateFlow<DataState<List<VerificationCodeItem>>>
/**
* Flow for getting a DataState with a single verification code item.
*/
fun getTotpCodeStateFlow(
userId: String,
cipher: CipherView,
): StateFlow<DataState<VerificationCodeItem?>>
}

View File

@@ -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<String, StateFlow<DataState<VerificationCodeItem?>>>()
override fun getTotpCodesStateFlow(
userId: String,
cipherList: List<CipherView>,
): StateFlow<DataState<List<VerificationCodeItem>>> {
// 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<DataState<VerificationCodeItem?>> =
getTotpCodeStateFlowInternal(
userId = userId,
cipher = cipher,
)
@Suppress("LongMethod")
private fun getTotpCodeStateFlowInternal(
userId: String,
cipher: CipherView,
): StateFlow<DataState<VerificationCodeItem?>> {
val cipherId = cipher.id ?: return MutableStateFlow(DataState.Loaded(null))
return mutableVerificationCodeStateFlowMap.getOrPut(cipherId) {
flow<DataState<VerificationCodeItem?>> {
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()
}

View File

@@ -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,
)
}

View File

@@ -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<LoginUriView>?,
val id: String,
val name: String,
val username: String?,
)

View File

@@ -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<DataState<SendView?>>
/**
* 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<DataState<List<VerificationCodeItem>>>
/**
* Emits the totp code result flow to listeners.
*/

View File

@@ -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<DataState<List<VerificationCodeItem>>> {
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)
}

View File

@@ -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,
)
}

View File

@@ -134,8 +134,6 @@ fun VerificationCodeScreen(
is VerificationCodeState.ViewState.Loading -> {
BitwardenLoadingContent(modifier = modifier)
}
is VerificationCodeState.ViewState.NoItems -> Unit
}
}
}

View File

@@ -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<VaultData>) {
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<List<VerificationCodeItem>>,
) {
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<VaultData>) {
if (vaultData.data != null) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
private fun vaultNoNetworkReceive(
verificationCodeData:
DataState.NoNetwork<List<VerificationCodeItem>>,
) {
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<VaultData>) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = false)
private fun vaultPendingReceive(
verificationCodeData: DataState.Pending<List<VerificationCodeItem>>,
) {
updateStateWithVerificationCodeData(
verificationCodeData = verificationCodeData.data,
clearDialogState = false,
)
}
private fun vaultLoadedReceive(vaultData: DataState.Loaded<VaultData>) {
updateStateWithVaultData(vaultData = vaultData.data, clearDialogState = true)
private fun vaultLoadedReceive(
verificationCodeData:
DataState.Loaded<List<VerificationCodeItem>>,
) {
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<VaultData>) {
private fun vaultErrorReceive(vaultData: DataState.Error<List<VerificationCodeItem>>) {
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<VerificationCodeItem>,
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<VaultData>,
data class AuthCodesReceive(
val verificationCodeData: DataState<List<VerificationCodeItem>>,
) : Internal()
}
}

View File

@@ -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<CipherView>.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<CipherView>.toDisplayItemList(
baseIconUrl: String,
isIconLoadingDisabled: Boolean,
): List<VerificationCodeDisplayItem> =
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,
),
)

View File

@@ -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<VerificationCodeItem>()), 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<VerificationCodeItem>()), 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())
}
}
}

View File

@@ -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<List<VerificationCodeItem>>>(
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<List<SyncResponseJson.Cipher>>()
val collectionsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Collection>>()
val foldersFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Folder>>()
val sendsFlow = bufferedMutableSharedFlow<List<SyncResponseJson.Send>>()
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<String, String> = emptyMap(),
@@ -2367,7 +2483,7 @@ class VaultRepositoryTest {
return mockInstant
}
//endregion Helper functions
//endregion Helper functions
}
private val MOCK_PROFILE = AccountJson.Profile(

View File

@@ -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)

View File

@@ -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<VaultData>>(DataState.Loading)
private val mutableAuthCodeFlow =
MutableStateFlow<DataState<List<VerificationCodeItem>>>(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<VaultData>(
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<VaultData>()
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,
),
)
},
)
}

View File

@@ -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",
)