mirror of
https://github.com/bitwarden/android.git
synced 2024-10-31 15:15:34 +03:00
BIT-541, 1370 Adding icon loading for login items (#654)
This commit is contained in:
parent
ff7a015472
commit
a87bcd28ff
22 changed files with 599 additions and 67 deletions
|
@ -6,6 +6,7 @@ import java.net.URI
|
|||
|
||||
private const val DEFAULT_WEB_VAULT_URL: String = "https://vault.bitwarden.com"
|
||||
private const val DEFAULT_WEB_SEND_URL: String = "https://send.bitwarden.com/#"
|
||||
private const val DEFAULT_ICON_URL: String = "https://icons.bitwarden.net/"
|
||||
|
||||
/**
|
||||
* Returns the base web vault URL. This will check for a custom [EnvironmentUrlDataJson.webVault]
|
||||
|
@ -37,6 +38,17 @@ val EnvironmentUrlDataJson.baseWebSendUrl: String
|
|||
?.let { "$it/#/send/" }
|
||||
?: DEFAULT_WEB_SEND_URL
|
||||
|
||||
/**
|
||||
* Returns a base icon url based on the environment or the default value if values are missing.
|
||||
*/
|
||||
val EnvironmentUrlDataJson.baseIconUrl: String
|
||||
get() =
|
||||
this
|
||||
.icon
|
||||
.takeIf { !it.isNullOrBlank() }
|
||||
?: base.takeIf { it.isNotBlank() }?.let { "$it/icons" }
|
||||
?: DEFAULT_ICON_URL
|
||||
|
||||
/**
|
||||
* Returns the appropriate pre-defined labels for environments matching the known US/EU values.
|
||||
* Otherwise returns the host of the custom base URL.
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
package com.x8bit.bitwarden.ui.platform.components
|
||||
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi
|
||||
import com.bumptech.glide.integration.compose.GlideImage
|
||||
import com.bumptech.glide.integration.compose.placeholder
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
|
||||
/**
|
||||
* Represents a Bitwarden icon that is either locally loaded or loaded using glide.
|
||||
*
|
||||
* @param iconData Label for the text field.
|
||||
* @param tint the color to be applied as the tint for the icon.
|
||||
* @param modifier A [Modifier] for the composable.
|
||||
* @param contentDescription A description of the switch's UI for accessibility purposes.
|
||||
*/
|
||||
@OptIn(ExperimentalGlideComposeApi::class)
|
||||
@Composable
|
||||
fun BitwardenIcon(
|
||||
iconData: IconData,
|
||||
tint: Color,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
) {
|
||||
when (iconData) {
|
||||
is IconData.Network -> {
|
||||
GlideImage(
|
||||
model = iconData.uri,
|
||||
failure = placeholder(iconData.fallbackIconRes),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
) {
|
||||
it.placeholder(iconData.fallbackIconRes)
|
||||
}
|
||||
}
|
||||
|
||||
is IconData.Local -> {
|
||||
Icon(
|
||||
painter = painterResource(id = iconData.iconRes),
|
||||
contentDescription = contentDescription,
|
||||
tint = tint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.collections.immutable.PersistentList
|
||||
|
@ -52,7 +53,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
@Composable
|
||||
fun BitwardenListItem(
|
||||
label: String,
|
||||
startIcon: Painter,
|
||||
startIcon: IconData,
|
||||
onClick: () -> Unit,
|
||||
selectionDataList: PersistentList<SelectionItemData>,
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -73,8 +74,8 @@ fun BitwardenListItem(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
painter = startIcon,
|
||||
BitwardenIcon(
|
||||
iconData = startIcon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.size(24.dp),
|
||||
|
@ -159,7 +160,7 @@ private fun BitwardenListItem_preview() {
|
|||
BitwardenListItem(
|
||||
label = "Sample Label",
|
||||
supportingLabel = "Jan 3, 2024, 10:35 AM",
|
||||
startIcon = painterResource(id = R.drawable.ic_send_text),
|
||||
startIcon = IconData.Local(R.drawable.ic_send_text),
|
||||
onClick = {},
|
||||
selectionDataList = persistentListOf(),
|
||||
)
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
package com.x8bit.bitwarden.ui.platform.components.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* A class to denote the type of icon being passed.
|
||||
*/
|
||||
@Parcelize
|
||||
sealed class IconData : Parcelable {
|
||||
|
||||
/**
|
||||
* Data class representing the resources required for an icon.
|
||||
*
|
||||
* @property iconRes the resource for the local icon.
|
||||
*/
|
||||
data class Local(
|
||||
val iconRes: Int,
|
||||
) : IconData()
|
||||
|
||||
/**
|
||||
* Data class representing the resources required for a network-based icon.
|
||||
*
|
||||
* @property uri the link for the icon.
|
||||
* @property fallbackIconRes fallback resource if the image cannot be loaded.
|
||||
*/
|
||||
data class Network(
|
||||
val uri: String,
|
||||
val fallbackIconRes: Int,
|
||||
) : IconData()
|
||||
}
|
|
@ -16,6 +16,7 @@ import com.x8bit.bitwarden.R
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenGroupItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderTextWithSupportLabel
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.tools.feature.send.handlers.SendHandlers
|
||||
|
||||
/**
|
||||
|
@ -75,7 +76,7 @@ fun SendContent(
|
|||
|
||||
items(state.sendItems) {
|
||||
SendListItem(
|
||||
startIcon = painterResource(id = it.type.iconRes),
|
||||
startIcon = IconData.Local(it.type.iconRes),
|
||||
label = it.name,
|
||||
supportingLabel = it.deletionDate,
|
||||
trailingLabelIcons = it.iconList,
|
||||
|
|
|
@ -14,6 +14,7 @@ import com.x8bit.bitwarden.R
|
|||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
|
||||
import com.x8bit.bitwarden.ui.platform.components.SelectionItemData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconResource
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import com.x8bit.bitwarden.ui.platform.util.persistentListOfNotNull
|
||||
|
@ -40,7 +41,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.model.SendStatusIcon
|
|||
fun SendListItem(
|
||||
label: String,
|
||||
supportingLabel: String,
|
||||
startIcon: Painter,
|
||||
startIcon: IconData,
|
||||
trailingLabelIcons: List<SendStatusIcon>,
|
||||
onClick: () -> Unit,
|
||||
onEditClick: () -> Unit,
|
||||
|
@ -111,7 +112,7 @@ private fun SendListItem_preview() {
|
|||
SendListItem(
|
||||
label = "Sample Label",
|
||||
supportingLabel = "Jan 3, 2024, 10:35 AM",
|
||||
startIcon = painterResource(id = R.drawable.ic_send_text),
|
||||
startIcon = IconData.Local(R.drawable.ic_send_text),
|
||||
trailingLabelIcons = emptyList(),
|
||||
onClick = {},
|
||||
onCopyClick = {},
|
||||
|
|
|
@ -6,7 +6,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.x8bit.bitwarden.R
|
||||
|
@ -36,7 +35,7 @@ fun VaultItemListingContent(
|
|||
}
|
||||
items(state.displayItemList) {
|
||||
VaultEntryListItem(
|
||||
startIcon = painterResource(id = it.iconRes),
|
||||
startIcon = it.iconData,
|
||||
label = it.title,
|
||||
supportingLabel = it.subtitle,
|
||||
onClick = { vaultItemClick(it.id) },
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
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.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.itemlisting.util.determineListingPredicate
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toItemListingType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.toViewState
|
||||
|
@ -30,16 +33,25 @@ import javax.inject.Inject
|
|||
class VaultItemListingViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val environmentRepository: EnvironmentRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : BaseViewModel<VaultItemListingState, VaultItemListingEvent, VaultItemListingsAction>(
|
||||
initialState = VaultItemListingState(
|
||||
itemListingType = VaultItemListingArgs(savedStateHandle = savedStateHandle)
|
||||
.vaultItemListingType
|
||||
.toItemListingType(),
|
||||
viewState = VaultItemListingState.ViewState.Loading,
|
||||
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
|
||||
),
|
||||
) {
|
||||
|
||||
init {
|
||||
settingsRepository
|
||||
.isIconLoadingDisabledFlow
|
||||
.onEach { sendAction(VaultItemListingsAction.Internal.IconLoadingSettingReceive(it)) }
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
vaultRepository
|
||||
.vaultDataStateFlow
|
||||
.onEach { sendAction(VaultItemListingsAction.Internal.VaultDataReceive(it)) }
|
||||
|
@ -54,6 +66,8 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
is VaultItemListingsAction.AddVaultItemClick -> handleAddVaultItemClick()
|
||||
is VaultItemListingsAction.RefreshClick -> handleRefreshClick()
|
||||
is VaultItemListingsAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||
is VaultItemListingsAction.Internal.IconLoadingSettingReceive ->
|
||||
handleIconsSettingReceived(action)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -99,6 +113,18 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
is DataState.Pending -> vaultPendingReceive(vaultData = vaultData)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIconsSettingReceived(
|
||||
action: VaultItemListingsAction.Internal.IconLoadingSettingReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isIconLoadingDisabled = action.isIconLoadingDisabled)
|
||||
}
|
||||
|
||||
vaultRepository.vaultDataStateFlow.value.data?.let { vaultData ->
|
||||
updateStateWithVaultData(vaultData)
|
||||
}
|
||||
}
|
||||
//endregion VaultItemListing Handlers
|
||||
|
||||
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
|
||||
|
@ -159,7 +185,10 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
.filter { cipherView ->
|
||||
cipherView.determineListingPredicate(currentState.itemListingType)
|
||||
}
|
||||
.toViewState(),
|
||||
.toViewState(
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -171,6 +200,8 @@ class VaultItemListingViewModel @Inject constructor(
|
|||
data class VaultItemListingState(
|
||||
val itemListingType: ItemListingType,
|
||||
val viewState: ViewState,
|
||||
val baseIconUrl: String,
|
||||
val isIconLoadingDisabled: Boolean,
|
||||
) {
|
||||
|
||||
/**
|
||||
|
@ -214,16 +245,13 @@ data class VaultItemListingState(
|
|||
* @property id the id of the item.
|
||||
* @property title title of the item.
|
||||
* @property subtitle subtitle of the item (nullable).
|
||||
* @property uri uri for the icon to be displayed (nullable).
|
||||
* @property iconRes the icon to be displayed.
|
||||
* @property iconData data for the icon to be displayed (nullable).
|
||||
*/
|
||||
data class DisplayItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val uri: String?,
|
||||
@DrawableRes
|
||||
val iconRes: Int,
|
||||
val iconData: IconData,
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -399,6 +427,13 @@ sealed class VaultItemListingsAction {
|
|||
*/
|
||||
sealed class Internal : VaultItemListingsAction() {
|
||||
|
||||
/**
|
||||
* Indicates the icon setting was received.
|
||||
*/
|
||||
data class IconLoadingSettingReceive(
|
||||
val isIconLoadingDisabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates vault data was received.
|
||||
*/
|
||||
|
|
|
@ -6,7 +6,9 @@ import com.bitwarden.core.CipherView
|
|||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.toLoginIconData
|
||||
|
||||
/**
|
||||
* Determines a predicate to filter a list of [CipherView] based on the
|
||||
|
@ -48,9 +50,17 @@ fun CipherView.determineListingPredicate(
|
|||
/**
|
||||
* Transforms a list of [CipherView] into [VaultItemListingState.ViewState].
|
||||
*/
|
||||
fun List<CipherView>.toViewState(): VaultItemListingState.ViewState =
|
||||
fun List<CipherView>.toViewState(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
): VaultItemListingState.ViewState =
|
||||
if (isNotEmpty()) {
|
||||
VaultItemListingState.ViewState.Content(displayItemList = toDisplayItemList())
|
||||
VaultItemListingState.ViewState.Content(
|
||||
displayItemList = toDisplayItemList(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
VaultItemListingState.ViewState.NoItems
|
||||
}
|
||||
|
@ -82,18 +92,49 @@ fun VaultItemListingState.ItemListingType.updateWithAdditionalDataIfNecessary(
|
|||
is VaultItemListingState.ItemListingType.Trash -> this
|
||||
}
|
||||
|
||||
private fun List<CipherView>.toDisplayItemList(): List<VaultItemListingState.DisplayItem> =
|
||||
this.map { it.toDisplayItem() }
|
||||
private fun List<CipherView>.toDisplayItemList(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
): List<VaultItemListingState.DisplayItem> =
|
||||
this.map {
|
||||
it.toDisplayItem(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CipherView.toDisplayItem(): VaultItemListingState.DisplayItem =
|
||||
private fun CipherView.toDisplayItem(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
): VaultItemListingState.DisplayItem =
|
||||
VaultItemListingState.DisplayItem(
|
||||
id = id.orEmpty(),
|
||||
title = name,
|
||||
subtitle = subtitle,
|
||||
iconRes = type.iconRes,
|
||||
uri = uri,
|
||||
iconData = this.toIconData(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
),
|
||||
)
|
||||
|
||||
private fun CipherView.toIconData(
|
||||
baseIconUrl: String,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
): IconData {
|
||||
return when (this.type) {
|
||||
CipherType.LOGIN -> {
|
||||
login?.uris.toLoginIconData(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
IconData.Local(iconRes = this.type.iconRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private val CipherView.subtitle: String?
|
||||
get() = when (type) {
|
||||
|
@ -122,18 +163,3 @@ private val CipherType.iconRes: Int
|
|||
CipherType.CARD -> R.drawable.ic_card_item
|
||||
CipherType.IDENTITY -> R.drawable.ic_identity_item
|
||||
}
|
||||
|
||||
private val CipherView.uri: String?
|
||||
get() = when (type) {
|
||||
CipherType.LOGIN -> {
|
||||
login
|
||||
?.uris
|
||||
?.firstOrNull()
|
||||
?.uri
|
||||
.orEmpty()
|
||||
}
|
||||
|
||||
CipherType.SECURE_NOTE -> null
|
||||
CipherType.CARD -> null
|
||||
CipherType.IDENTITY -> null
|
||||
}
|
||||
|
|
|
@ -82,7 +82,7 @@ fun VaultContent(
|
|||
|
||||
items(state.favoriteItems) { favoriteItem ->
|
||||
VaultEntryListItem(
|
||||
startIcon = painterResource(id = favoriteItem.startIcon),
|
||||
startIcon = favoriteItem.startIcon,
|
||||
label = favoriteItem.name(),
|
||||
supportingLabel = favoriteItem.supportingLabel?.invoke(),
|
||||
onClick = { vaultItemClick(favoriteItem) },
|
||||
|
@ -234,8 +234,9 @@ fun VaultContent(
|
|||
)
|
||||
}
|
||||
items(state.noFolderItems) { noFolderItem ->
|
||||
|
||||
VaultEntryListItem(
|
||||
startIcon = painterResource(id = noFolderItem.startIcon),
|
||||
startIcon = noFolderItem.startIcon,
|
||||
label = noFolderItem.name(),
|
||||
supportingLabel = noFolderItem.supportingLabel?.invoke(),
|
||||
onClick = { vaultItemClick(noFolderItem) },
|
||||
|
|
|
@ -3,13 +3,12 @@ package com.x8bit.bitwarden.ui.vault.feature.vault
|
|||
import android.widget.Toast
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.components.BitwardenListItem
|
||||
import com.x8bit.bitwarden.ui.platform.components.SelectionItemData
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.platform.theme.BitwardenTheme
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
|
@ -24,7 +23,7 @@ import kotlinx.collections.immutable.persistentListOf
|
|||
*/
|
||||
@Composable
|
||||
fun VaultEntryListItem(
|
||||
startIcon: Painter,
|
||||
startIcon: IconData,
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
|
@ -54,7 +53,7 @@ fun VaultEntryListItem(
|
|||
private fun VaultEntryListItem_preview() {
|
||||
BitwardenTheme {
|
||||
VaultEntryListItem(
|
||||
startIcon = painterResource(id = R.drawable.ic_login_item),
|
||||
startIcon = IconData.Local(R.drawable.ic_login_item),
|
||||
label = "Example Login",
|
||||
supportingLabel = "Username",
|
||||
onClick = {},
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.x8bit.bitwarden.R
|
||||
|
@ -10,6 +9,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.SwitchAccountResult
|
|||
import com.x8bit.bitwarden.data.auth.repository.model.UserState
|
||||
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.repository.VaultRepository
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
|
||||
|
@ -18,6 +18,7 @@ import com.x8bit.bitwarden.ui.platform.base.util.asText
|
|||
import com.x8bit.bitwarden.ui.platform.base.util.concat
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.hexToColor
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.AccountSummary
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.util.initials
|
||||
|
@ -43,8 +44,8 @@ import javax.inject.Inject
|
|||
@HiltViewModel
|
||||
class VaultViewModel @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val vaultRepository: VaultRepository,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : BaseViewModel<VaultState, VaultEvent, VaultAction>(
|
||||
initialState = run {
|
||||
val userState = requireNotNull(authRepository.userStateFlow.value)
|
||||
|
@ -59,8 +60,10 @@ class VaultViewModel @Inject constructor(
|
|||
accountSummaries = accountSummaries,
|
||||
vaultFilterData = vaultFilterData,
|
||||
viewState = VaultState.ViewState.Loading,
|
||||
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
|
||||
isPremium = userState.activeAccount.isPremium,
|
||||
isPullToRefreshSettingEnabled = settingsRepository.getPullToRefreshEnabledFlow().value,
|
||||
baseIconUrl = userState.activeAccount.environment.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
},
|
||||
) {
|
||||
|
@ -77,6 +80,12 @@ class VaultViewModel @Inject constructor(
|
|||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
settingsRepository
|
||||
.isIconLoadingDisabledFlow
|
||||
.map { VaultAction.Internal.IconLoadingSettingReceive(it) }
|
||||
.onEach(::sendAction)
|
||||
.launchIn(viewModelScope)
|
||||
|
||||
vaultRepository
|
||||
.vaultDataStateFlow
|
||||
.onEach { sendAction(VaultAction.Internal.VaultDataReceive(vaultData = it)) }
|
||||
|
@ -118,6 +127,16 @@ class VaultViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleIconLoadingSettingReceive(
|
||||
action: VaultAction.Internal.IconLoadingSettingReceive,
|
||||
) {
|
||||
mutableStateFlow.update {
|
||||
it.copy(isIconLoadingDisabled = action.isIconLoadingDisabled)
|
||||
}
|
||||
|
||||
updateViewState(vaultRepository.vaultDataStateFlow.value)
|
||||
}
|
||||
|
||||
//region VaultAction Handlers
|
||||
private fun handleAddItemClick() {
|
||||
sendEvent(VaultEvent.NavigateToAddItemScreen)
|
||||
|
@ -252,6 +271,9 @@ class VaultViewModel @Inject constructor(
|
|||
|
||||
is VaultAction.Internal.UserStateUpdateReceive -> handleUserStateUpdateReceive(action)
|
||||
is VaultAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
|
||||
is VaultAction.Internal.IconLoadingSettingReceive -> handleIconLoadingSettingReceive(
|
||||
action,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -308,8 +330,10 @@ class VaultViewModel @Inject constructor(
|
|||
|
||||
private fun vaultErrorReceive(vaultData: DataState.Error<VaultData>) {
|
||||
mutableStateFlow.updateToErrorStateOrDialog(
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
vaultData = vaultData.data,
|
||||
vaultFilterType = vaultFilterTypeOrDefault,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
isPremium = state.isPremium,
|
||||
errorTitle = R.string.an_error_has_occurred.asText(),
|
||||
errorMessage = R.string.generic_error_message.asText(),
|
||||
|
@ -328,6 +352,8 @@ class VaultViewModel @Inject constructor(
|
|||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = vaultData.data.toViewState(
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
isPremium = state.isPremium,
|
||||
vaultFilterType = vaultFilterTypeOrDefault,
|
||||
),
|
||||
|
@ -343,10 +369,12 @@ class VaultViewModel @Inject constructor(
|
|||
|
||||
private fun vaultNoNetworkReceive(vaultData: DataState.NoNetwork<VaultData>) {
|
||||
mutableStateFlow.updateToErrorStateOrDialog(
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
vaultData = vaultData.data,
|
||||
vaultFilterType = vaultFilterTypeOrDefault,
|
||||
isPremium = state.isPremium,
|
||||
errorTitle = R.string.internet_connection_required_title.asText(),
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
errorMessage = R.string.internet_connection_required_message.asText(),
|
||||
)
|
||||
sendEvent(VaultEvent.DismissPullToRefresh)
|
||||
|
@ -357,6 +385,8 @@ class VaultViewModel @Inject constructor(
|
|||
mutableStateFlow.update {
|
||||
it.copy(
|
||||
viewState = vaultData.data.toViewState(
|
||||
baseIconUrl = state.baseIconUrl,
|
||||
isIconLoadingDisabled = state.isIconLoadingDisabled,
|
||||
isPremium = state.isPremium,
|
||||
vaultFilterType = vaultFilterTypeOrDefault,
|
||||
),
|
||||
|
@ -391,6 +421,8 @@ data class VaultState(
|
|||
val isSwitchingAccounts: Boolean = false,
|
||||
val isPremium: Boolean,
|
||||
private val isPullToRefreshSettingEnabled: Boolean,
|
||||
val baseIconUrl: String,
|
||||
val isIconLoadingDisabled: Boolean,
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
|
@ -536,8 +568,7 @@ data class VaultState(
|
|||
*/
|
||||
abstract val name: Text
|
||||
|
||||
@get:DrawableRes
|
||||
abstract val startIcon: Int
|
||||
abstract val startIcon: IconData
|
||||
|
||||
/**
|
||||
* An optional supporting label for the vault item that provides additional information.
|
||||
|
@ -555,9 +586,9 @@ data class VaultState(
|
|||
data class Login(
|
||||
override val id: String,
|
||||
override val name: Text,
|
||||
override val startIcon: IconData = IconData.Local(R.drawable.ic_login_item),
|
||||
val username: Text?,
|
||||
) : VaultItem() {
|
||||
override val startIcon: Int get() = R.drawable.ic_login_item
|
||||
override val supportingLabel: Text? get() = username
|
||||
}
|
||||
|
||||
|
@ -571,10 +602,10 @@ data class VaultState(
|
|||
data class Card(
|
||||
override val id: String,
|
||||
override val name: Text,
|
||||
override val startIcon: IconData = IconData.Local(R.drawable.ic_card_item),
|
||||
val brand: Text? = null,
|
||||
val lastFourDigits: Text? = null,
|
||||
) : VaultItem() {
|
||||
override val startIcon: Int get() = R.drawable.ic_card_item
|
||||
override val supportingLabel: Text?
|
||||
get() = when {
|
||||
brand != null && lastFourDigits != null -> brand
|
||||
|
@ -597,9 +628,9 @@ data class VaultState(
|
|||
data class Identity(
|
||||
override val id: String,
|
||||
override val name: Text,
|
||||
override val startIcon: IconData = IconData.Local(R.drawable.ic_identity_item),
|
||||
val firstName: Text?,
|
||||
) : VaultItem() {
|
||||
override val startIcon: Int get() = R.drawable.ic_identity_item
|
||||
override val supportingLabel: Text? get() = firstName
|
||||
}
|
||||
|
||||
|
@ -611,8 +642,8 @@ data class VaultState(
|
|||
data class SecureNote(
|
||||
override val id: String,
|
||||
override val name: Text,
|
||||
override val startIcon: IconData = IconData.Local(R.drawable.ic_secure_note_item),
|
||||
) : VaultItem() {
|
||||
override val startIcon: Int get() = R.drawable.ic_secure_note_item
|
||||
override val supportingLabel: Text? get() = null
|
||||
}
|
||||
}
|
||||
|
@ -841,6 +872,13 @@ sealed class VaultAction {
|
|||
*/
|
||||
sealed class Internal : VaultAction() {
|
||||
|
||||
/**
|
||||
* Indicates that the icon loading setting has been changed.
|
||||
*/
|
||||
data class IconLoadingSettingReceive(
|
||||
val isIconLoadingDisabled: Boolean,
|
||||
) : Internal()
|
||||
|
||||
/**
|
||||
* Indicates that the pull to refresh feature toggle has changed.
|
||||
*/
|
||||
|
@ -862,9 +900,12 @@ sealed class VaultAction {
|
|||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun MutableStateFlow<VaultState>.updateToErrorStateOrDialog(
|
||||
baseIconUrl: String,
|
||||
vaultData: VaultData?,
|
||||
vaultFilterType: VaultFilterType,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
isPremium: Boolean,
|
||||
errorTitle: Text,
|
||||
errorMessage: Text,
|
||||
|
@ -873,8 +914,10 @@ private fun MutableStateFlow<VaultState>.updateToErrorStateOrDialog(
|
|||
if (vaultData != null) {
|
||||
it.copy(
|
||||
viewState = vaultData.toViewState(
|
||||
baseIconUrl = baseIconUrl,
|
||||
isPremium = isPremium,
|
||||
vaultFilterType = vaultFilterType,
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
),
|
||||
dialog = VaultState.DialogState.Error(
|
||||
title = errorTitle,
|
||||
|
|
|
@ -1,19 +1,28 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.CipherView
|
||||
import com.bitwarden.core.CollectionView
|
||||
import com.bitwarden.core.FolderView
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.vault.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
|
||||
private const val ANDROID_URI = "androidapp://"
|
||||
private const val IOS_URI = "iosapp://"
|
||||
|
||||
/**
|
||||
* Transforms [VaultData] into [VaultState.ViewState] using the given [vaultFilterType].
|
||||
*/
|
||||
fun VaultData.toViewState(
|
||||
isPremium: Boolean,
|
||||
isIconLoadingDisabled: Boolean,
|
||||
baseIconUrl: String,
|
||||
vaultFilterType: VaultFilterType,
|
||||
): VaultState.ViewState {
|
||||
val filteredCipherViewList = cipherViewList.toFilteredList(vaultFilterType)
|
||||
|
@ -36,7 +45,12 @@ fun VaultData.toViewState(
|
|||
.count { it.type == CipherType.SECURE_NOTE },
|
||||
favoriteItems = filteredCipherViewList
|
||||
.filter { it.favorite }
|
||||
.mapNotNull { it.toVaultItemOrNull() },
|
||||
.mapNotNull {
|
||||
it.toVaultItemOrNull(
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
baseIconUrl = baseIconUrl,
|
||||
)
|
||||
},
|
||||
folderItems = filteredFolderViewList.map { folderView ->
|
||||
VaultState.ViewState.FolderItem(
|
||||
id = folderView.id,
|
||||
|
@ -47,7 +61,12 @@ fun VaultData.toViewState(
|
|||
},
|
||||
noFolderItems = filteredCipherViewList
|
||||
.filter { it.folderId.isNullOrBlank() }
|
||||
.mapNotNull { it.toVaultItemOrNull() },
|
||||
.mapNotNull {
|
||||
it.toVaultItemOrNull(
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
baseIconUrl = baseIconUrl,
|
||||
)
|
||||
},
|
||||
collectionItems = filteredCollectionViewList
|
||||
.filter { it.id != null }
|
||||
.map { collectionView ->
|
||||
|
@ -67,17 +86,66 @@ fun VaultData.toViewState(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to build the icon data for login item icons.
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
fun List<LoginUriView>?.toLoginIconData(
|
||||
isIconLoadingDisabled: Boolean,
|
||||
baseIconUrl: String,
|
||||
): IconData {
|
||||
val localIconData = IconData.Local(R.drawable.ic_login_item)
|
||||
|
||||
var uri = this
|
||||
?.map { it.uri }
|
||||
?.firstOrNull { uri -> uri?.contains(".") == true }
|
||||
?: return localIconData
|
||||
|
||||
if (uri.startsWith(ANDROID_URI)) {
|
||||
return IconData.Local(R.drawable.ic_settings)
|
||||
}
|
||||
|
||||
if (uri.startsWith(IOS_URI)) {
|
||||
return IconData.Local(R.drawable.ic_settings)
|
||||
}
|
||||
|
||||
if (isIconLoadingDisabled) {
|
||||
return localIconData
|
||||
}
|
||||
|
||||
if (!uri.contains("://")) {
|
||||
uri = "http://$uri"
|
||||
}
|
||||
|
||||
val iconUri = Uri.parse(uri)
|
||||
val hostname = iconUri.host
|
||||
|
||||
val url = "$baseIconUrl/$hostname/icon.png"
|
||||
|
||||
return IconData.Network(
|
||||
uri = url,
|
||||
fallbackIconRes = R.drawable.ic_login_item,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a [CipherView] into a [VaultState.ViewState.VaultItem].
|
||||
*/
|
||||
@Suppress("MagicNumber")
|
||||
private fun CipherView.toVaultItemOrNull(): VaultState.ViewState.VaultItem? {
|
||||
private fun CipherView.toVaultItemOrNull(
|
||||
isIconLoadingDisabled: Boolean,
|
||||
baseIconUrl: String,
|
||||
): VaultState.ViewState.VaultItem? {
|
||||
val id = this.id ?: return null
|
||||
return when (type) {
|
||||
CipherType.LOGIN -> VaultState.ViewState.VaultItem.Login(
|
||||
id = id,
|
||||
name = name.asText(),
|
||||
username = login?.username?.asText(),
|
||||
startIcon = login?.uris.toLoginIconData(
|
||||
isIconLoadingDisabled = isIconLoadingDisabled,
|
||||
baseIconUrl = baseIconUrl,
|
||||
),
|
||||
)
|
||||
|
||||
CipherType.SECURE_NOTE -> VaultState.ViewState.VaultItem.SecureNote(
|
||||
|
|
|
@ -170,6 +170,39 @@ class EnvironmentUrlsDataJsonExtensionsTest {
|
|||
(null as EnvironmentUrlDataJson?).toEnvironmentUrlsOrDefault(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toIconBaseurl should return icon if value is present`() {
|
||||
assertEquals(
|
||||
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA.baseIconUrl,
|
||||
"icon",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toIconBaseurl should return base value if icon is null`() {
|
||||
assertEquals(
|
||||
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA
|
||||
.copy(icon = null)
|
||||
.baseIconUrl,
|
||||
"base/icons",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toIconBaseurl should return default url if base is empty and icon is null`() {
|
||||
val expectedUrl = "https://icons.bitwarden.net/"
|
||||
|
||||
assertEquals(
|
||||
expectedUrl,
|
||||
DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA
|
||||
.copy(
|
||||
base = "",
|
||||
icon = null,
|
||||
)
|
||||
.baseIconUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val DEFAULT_CUSTOM_ENVIRONMENT_URL_DATA = EnvironmentUrlDataJson(
|
||||
|
|
|
@ -85,7 +85,7 @@ fun createMockLoginView(number: Int): LoginView =
|
|||
*/
|
||||
fun createMockUriView(number: Int): LoginUriView =
|
||||
LoginUriView(
|
||||
uri = "mockUri-$number",
|
||||
uri = "www.mockuri$number.com",
|
||||
match = UriMatchType.HOST,
|
||||
)
|
||||
|
||||
|
|
|
@ -10,9 +10,12 @@ import androidx.compose.ui.test.onNodeWithText
|
|||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.util.isProgressBar
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
|
@ -393,6 +396,8 @@ class VaultItemListingScreenTest : BaseComposeTest() {
|
|||
private val DEFAULT_STATE = VaultItemListingState(
|
||||
itemListingType = VaultItemListingState.ItemListingType.Login,
|
||||
viewState = VaultItemListingState.ViewState.Loading,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
|
||||
|
@ -400,6 +405,5 @@ private fun createDisplayItem(number: Int): VaultItemListingState.DisplayItem =
|
|||
id = "mockId-$number",
|
||||
title = "mockTitle-$number",
|
||||
subtitle = "mockSubtitle-$number",
|
||||
uri = "mockUri-$number",
|
||||
iconRes = R.drawable.ic_card_item,
|
||||
iconData = IconData.Local(R.drawable.ic_card_item),
|
||||
)
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import app.cash.turbine.test
|
||||
import com.x8bit.bitwarden.R
|
||||
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.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
|
||||
|
@ -17,10 +22,14 @@ import com.x8bit.bitwarden.ui.vault.feature.itemlisting.util.createMockItemListi
|
|||
import com.x8bit.bitwarden.ui.vault.model.VaultItemListingType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class VaultItemListingViewModelTest : BaseViewModelTest() {
|
||||
|
@ -31,6 +40,16 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
every { vaultDataStateFlow } returns mutableVaultDataStateFlow
|
||||
every { sync() } returns Unit
|
||||
}
|
||||
private val environmentRepository: EnvironmentRepository = mockk {
|
||||
every { environment } returns Environment.Us
|
||||
every { environmentStateFlow } returns mockk()
|
||||
}
|
||||
|
||||
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
|
||||
private val settingsRepository: SettingsRepository = mockk() {
|
||||
every { isIconLoadingDisabled } returns false
|
||||
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
|
||||
}
|
||||
private val initialState = createVaultItemListingState()
|
||||
private val initialSavedStateHandle = createSavedStateHandleWithVaultItemListingType(
|
||||
vaultItemListingType = VaultItemListingType.Login,
|
||||
|
@ -92,6 +111,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
@Test
|
||||
fun `vaultDataStateFlow Loaded with items should update ViewState to Content`() =
|
||||
runTest {
|
||||
setupMockUri()
|
||||
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Loaded(
|
||||
data = VaultData(
|
||||
|
@ -178,6 +199,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Pending with data should update state to Content`() = runTest {
|
||||
setupMockUri()
|
||||
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Pending(
|
||||
data = VaultData(
|
||||
|
@ -267,6 +290,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `vaultDataStateFlow Error with data should update state to Content`() = runTest {
|
||||
setupMockUri()
|
||||
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.Error(
|
||||
data = VaultData(
|
||||
|
@ -291,6 +316,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
unmockkStatic(Uri::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -363,6 +390,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
|
||||
@Test
|
||||
fun `vaultDataStateFlow NoNetwork with data should update state to Content`() = runTest {
|
||||
setupMockUri()
|
||||
|
||||
mutableVaultDataStateFlow.tryEmit(
|
||||
value = DataState.NoNetwork(
|
||||
data = VaultData(
|
||||
|
@ -386,6 +415,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
),
|
||||
viewModel.stateFlow.value,
|
||||
)
|
||||
|
||||
unmockkStatic(Uri::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -434,6 +465,16 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `icon loading state updates should update isIconLoadingDisabled`() = runTest {
|
||||
val viewModel = createVaultItemListingViewModel()
|
||||
|
||||
assertFalse(viewModel.stateFlow.value.isIconLoadingDisabled)
|
||||
|
||||
mutableIsIconLoadingDisabledFlow.value = true
|
||||
assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled)
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod")
|
||||
private fun createSavedStateHandleWithVaultItemListingType(
|
||||
vaultItemListingType: VaultItemListingType,
|
||||
|
@ -464,6 +505,13 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun setupMockUri() {
|
||||
mockkStatic(Uri::class)
|
||||
val uriMock = mockk<Uri>()
|
||||
every { Uri.parse(any()) } returns uriMock
|
||||
every { uriMock.host } returns "www.mockuri.com"
|
||||
}
|
||||
|
||||
private fun createVaultItemListingViewModel(
|
||||
savedStateHandle: SavedStateHandle = initialSavedStateHandle,
|
||||
vaultRepository: VaultRepository = this.vaultRepository,
|
||||
|
@ -471,6 +519,8 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
VaultItemListingViewModel(
|
||||
savedStateHandle = savedStateHandle,
|
||||
vaultRepository = vaultRepository,
|
||||
environmentRepository = environmentRepository,
|
||||
settingsRepository = settingsRepository,
|
||||
)
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
|
@ -481,5 +531,7 @@ class VaultItemListingViewModelTest : BaseViewModelTest() {
|
|||
VaultItemListingState(
|
||||
itemListingType = itemListingType,
|
||||
viewState = viewState,
|
||||
baseIconUrl = environmentRepository.environment.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = settingsRepository.isIconLoadingDisabled,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.core.CipherType
|
||||
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.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
|
@ -244,6 +251,11 @@ class VaultItemListingDataExtensionsTest {
|
|||
|
||||
@Test
|
||||
fun `toViewState should transform a list of CipherViews into a ViewState`() {
|
||||
mockkStatic(Uri::class)
|
||||
val uriMock = mockk<Uri>()
|
||||
every { Uri.parse(any()) } returns uriMock
|
||||
every { uriMock.host } returns "www.mockuri.com"
|
||||
|
||||
val cipherViewList = listOf(
|
||||
createMockCipherView(
|
||||
number = 1,
|
||||
|
@ -267,7 +279,10 @@ class VaultItemListingDataExtensionsTest {
|
|||
),
|
||||
)
|
||||
|
||||
val result = cipherViewList.toViewState()
|
||||
val result = cipherViewList.toViewState(
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
VaultItemListingState.ViewState.Content(
|
||||
|
@ -292,6 +307,8 @@ class VaultItemListingDataExtensionsTest {
|
|||
),
|
||||
result,
|
||||
)
|
||||
|
||||
unmockkStatic(Uri::class)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.x8bit.bitwarden.ui.vault.feature.itemlisting.util
|
|||
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.itemlisting.VaultItemListingState
|
||||
|
||||
/**
|
||||
|
@ -17,8 +18,10 @@ fun createMockItemListingDisplayItem(
|
|||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "mockUsername-$number",
|
||||
iconRes = R.drawable.ic_login_item,
|
||||
uri = "mockUri-$number",
|
||||
iconData = IconData.Network(
|
||||
"https://vault.bitwarden.com/icons/www.mockuri.com/icon.png",
|
||||
fallbackIconRes = R.drawable.ic_login_item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -27,8 +30,7 @@ fun createMockItemListingDisplayItem(
|
|||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = null,
|
||||
iconRes = R.drawable.ic_secure_note_item,
|
||||
uri = null,
|
||||
iconData = IconData.Local(R.drawable.ic_secure_note_item),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -37,8 +39,7 @@ fun createMockItemListingDisplayItem(
|
|||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "er-$number",
|
||||
iconRes = R.drawable.ic_card_item,
|
||||
uri = null,
|
||||
iconData = IconData.Local(R.drawable.ic_card_item),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -47,8 +48,7 @@ fun createMockItemListingDisplayItem(
|
|||
id = "mockId-$number",
|
||||
title = "mockName-$number",
|
||||
subtitle = "mockFirstName-${number}mockLastName-$number",
|
||||
iconRes = R.drawable.ic_identity_item,
|
||||
uri = null,
|
||||
iconData = IconData.Local(R.drawable.ic_identity_item),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,8 @@ import androidx.compose.ui.test.onNodeWithText
|
|||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import com.x8bit.bitwarden.R
|
||||
import com.x8bit.bitwarden.data.platform.repository.model.Environment
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.baseIconUrl
|
||||
import com.x8bit.bitwarden.data.platform.repository.util.bufferedMutableSharedFlow
|
||||
import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
|
@ -1071,6 +1073,8 @@ private val DEFAULT_STATE: VaultState = VaultState(
|
|||
viewState = VaultState.ViewState.Loading,
|
||||
isPremium = false,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = false,
|
||||
)
|
||||
|
||||
private val DEFAULT_CONTENT_VIEW_STATE: VaultState.ViewState.Content = VaultState.ViewState.Content(
|
||||
|
|
|
@ -10,6 +10,7 @@ import com.x8bit.bitwarden.data.auth.repository.model.UserState.SpecialCircumsta
|
|||
import com.x8bit.bitwarden.data.platform.repository.SettingsRepository
|
||||
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
|
||||
|
@ -31,12 +32,15 @@ import io.mockk.verify
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@Suppress("LargeClass")
|
||||
class VaultViewModelTest : BaseViewModelTest() {
|
||||
|
||||
private val mutablePullToRefreshEnabledFlow = MutableStateFlow(false)
|
||||
private val mutableIsIconLoadingDisabledFlow = MutableStateFlow(false)
|
||||
|
||||
private val mutableUserStateFlow =
|
||||
MutableStateFlow<UserState?>(DEFAULT_USER_STATE)
|
||||
|
@ -57,6 +61,8 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
|
||||
private val settingsRepository: SettingsRepository = mockk {
|
||||
every { getPullToRefreshEnabledFlow() } returns mutablePullToRefreshEnabledFlow
|
||||
every { isIconLoadingDisabledFlow } returns mutableIsIconLoadingDisabledFlow
|
||||
every { isIconLoadingDisabled } returns false
|
||||
}
|
||||
|
||||
private val vaultRepository: VaultRepository =
|
||||
|
@ -357,6 +363,8 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
viewState = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled,
|
||||
baseIconUrl = viewModel.stateFlow.value.baseIconUrl,
|
||||
),
|
||||
)
|
||||
.copy(
|
||||
|
@ -378,6 +386,8 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
viewState = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
vaultFilterType = VaultFilterType.MyVault,
|
||||
isIconLoadingDisabled = viewModel.stateFlow.value.isIconLoadingDisabled,
|
||||
baseIconUrl = viewModel.stateFlow.value.baseIconUrl,
|
||||
),
|
||||
),
|
||||
viewModel.stateFlow.value,
|
||||
|
@ -1032,6 +1042,16 @@ class VaultViewModelTest : BaseViewModelTest() {
|
|||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `icon loading state updates should update isIconLoadingDisabled`() {
|
||||
val viewModel = createViewModel()
|
||||
|
||||
assertFalse(viewModel.stateFlow.value.isIconLoadingDisabled)
|
||||
|
||||
mutableIsIconLoadingDisabledFlow.value = true
|
||||
assertTrue(viewModel.stateFlow.value.isIconLoadingDisabled)
|
||||
}
|
||||
|
||||
private fun createViewModel(): VaultViewModel =
|
||||
VaultViewModel(
|
||||
authRepository = authRepository,
|
||||
|
@ -1120,4 +1140,6 @@ private fun createMockVaultState(
|
|||
isSwitchingAccounts = false,
|
||||
isPremium = true,
|
||||
isPullToRefreshSettingEnabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
isIconLoadingDisabled = false,
|
||||
)
|
||||
|
|
|
@ -1,13 +1,25 @@
|
|||
package com.x8bit.bitwarden.ui.vault.feature.vault.util
|
||||
|
||||
import android.net.Uri
|
||||
import com.bitwarden.core.CipherType
|
||||
import com.bitwarden.core.LoginUriView
|
||||
import com.bitwarden.core.UriMatchType
|
||||
import com.x8bit.bitwarden.R
|
||||
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.repository.model.VaultData
|
||||
import com.x8bit.bitwarden.ui.platform.base.util.asText
|
||||
import com.x8bit.bitwarden.ui.platform.components.model.IconData
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.VaultState
|
||||
import com.x8bit.bitwarden.ui.vault.feature.vault.model.VaultFilterType
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkStatic
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
|
@ -25,6 +37,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
)
|
||||
|
||||
|
@ -72,6 +86,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.MyVault,
|
||||
)
|
||||
|
||||
|
@ -116,6 +132,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.OrganizationVault(
|
||||
organizationId = "mockOrganizationId-1",
|
||||
organizationName = "Mock Organization 1",
|
||||
|
@ -156,6 +174,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
)
|
||||
|
||||
|
@ -176,6 +196,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
)
|
||||
|
||||
|
@ -197,6 +219,8 @@ class VaultDataExtensionsTest {
|
|||
|
||||
val actual = vaultData.toViewState(
|
||||
isPremium = true,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
)
|
||||
|
||||
|
@ -230,6 +254,8 @@ class VaultDataExtensionsTest {
|
|||
val actual = vaultData.toViewState(
|
||||
isPremium = false,
|
||||
vaultFilterType = VaultFilterType.AllVaults,
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
|
@ -248,4 +274,111 @@ class VaultDataExtensionsTest {
|
|||
actual,
|
||||
)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toLoginIconData should return a IconData Local type if isIconLoadingDisabled is true`() {
|
||||
val actual =
|
||||
createMockCipherView(
|
||||
number = 1,
|
||||
cipherType = CipherType.LOGIN,
|
||||
)
|
||||
.login
|
||||
?.uris
|
||||
.toLoginIconData(
|
||||
isIconLoadingDisabled = true,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
val expected = IconData.Local(iconRes = R.drawable.ic_login_item)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toLoginIconData should return a IconData Local type if no valid uris are found`() {
|
||||
val actual = listOf(
|
||||
LoginUriView(
|
||||
uri = "",
|
||||
match = UriMatchType.HOST,
|
||||
),
|
||||
)
|
||||
.toLoginIconData(
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
val expected = IconData.Local(iconRes = R.drawable.ic_login_item)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toLoginIconData should return a IconData Local type if an Android uri is detected`() {
|
||||
val actual = listOf(
|
||||
LoginUriView(
|
||||
uri = "androidapp://test.com",
|
||||
match = UriMatchType.HOST,
|
||||
),
|
||||
)
|
||||
.toLoginIconData(
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
val expected = IconData.Local(iconRes = R.drawable.ic_settings)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toLoginIconData should return a IconData Local type if an iOS uri is detected`() {
|
||||
val actual = listOf(
|
||||
LoginUriView(
|
||||
uri = "iosapp://test.com",
|
||||
match = UriMatchType.HOST,
|
||||
),
|
||||
)
|
||||
.toLoginIconData(
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
val expected = IconData.Local(iconRes = R.drawable.ic_settings)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
@Test
|
||||
fun `toLoginIconData should return IconData Network type if isIconLoadingDisabled is false`() {
|
||||
mockkStatic(Uri::class)
|
||||
val uriMock = mockk<Uri>()
|
||||
every { Uri.parse(any()) } returns uriMock
|
||||
every { uriMock.host } returns "www.mockuri1.com"
|
||||
|
||||
val actual =
|
||||
createMockCipherView(
|
||||
number = 1,
|
||||
cipherType = CipherType.LOGIN,
|
||||
)
|
||||
.login
|
||||
?.uris
|
||||
.toLoginIconData(
|
||||
isIconLoadingDisabled = false,
|
||||
baseIconUrl = Environment.Us.environmentUrlData.baseIconUrl,
|
||||
)
|
||||
|
||||
val expected = IconData.Network(
|
||||
uri = "https://vault.bitwarden.com/icons/www.mockuri1.com/icon.png",
|
||||
fallbackIconRes = R.drawable.ic_login_item,
|
||||
)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
|
||||
unmockkStatic(Uri::class)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue