From a87bcd28ff97a1e1dd21276d7374e2143b4b9b4c Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Wed, 17 Jan 2024 21:14:23 -0600 Subject: [PATCH] BIT-541, 1370 Adding icon loading for login items (#654) --- .../util/EnvironmentUrlDataJsonExtensions.kt | 12 ++ .../ui/platform/components/BitwardenIcon.kt | 50 +++++++ .../platform/components/BitwardenListItem.kt | 9 +- .../ui/platform/components/model/IconData.kt | 31 ++++ .../ui/tools/feature/send/SendContent.kt | 3 +- .../ui/tools/feature/send/SendListItem.kt | 5 +- .../itemlisting/VaultItemListingContent.kt | 3 +- .../itemlisting/VaultItemListingViewModel.kt | 49 ++++++- .../util/VaultItemListingDataExtensions.kt | 70 ++++++--- .../ui/vault/feature/vault/VaultContent.kt | 5 +- .../vault/feature/vault/VaultEntryListItem.kt | 7 +- .../ui/vault/feature/vault/VaultViewModel.kt | 59 ++++++-- .../feature/vault/util/VaultDataExtensions.kt | 74 +++++++++- .../EnvironmentUrlsDataJsonExtensionsTest.kt | 33 +++++ .../datasource/sdk/model/CipherViewUtil.kt | 2 +- .../itemlisting/VaultItemListingScreenTest.kt | 8 +- .../VaultItemListingViewModelTest.kt | 52 +++++++ .../VaultItemListingDataExtensionsTest.kt | 19 ++- .../util/VaultItemListingDataUtil.kt | 16 +-- .../ui/vault/feature/vault/VaultScreenTest.kt | 4 + .../vault/feature/vault/VaultViewModelTest.kt | 22 +++ .../vault/util/VaultDataExtensionsTest.kt | 133 ++++++++++++++++++ 22 files changed, 599 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIcon.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconData.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt index cf2501516..6d1f4e792 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlDataJsonExtensions.kt @@ -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. diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIcon.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIcon.kt new file mode 100644 index 000000000..08e53a04f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenIcon.kt @@ -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, + ) + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListItem.kt index 5577149a6..363e0c93d 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListItem.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/BitwardenListItem.kt @@ -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, 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(), ) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconData.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconData.kt new file mode 100644 index 000000000..ba51a76fa --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/components/model/IconData.kt @@ -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() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt index c59b88b5f..3a201b7c9 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendContent.kt @@ -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, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendListItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendListItem.kt index 08f1e2ca3..b39a36fa6 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendListItem.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/tools/feature/send/SendListItem.kt @@ -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, 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 = {}, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt index 8812b1f03..f92cceb9e 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingContent.kt @@ -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) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt index 869abf80a..66027c956 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModel.kt @@ -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( 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) { @@ -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. */ diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt index 6a9073ff0..29d3fac7c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensions.kt @@ -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.toViewState(): VaultItemListingState.ViewState = +fun List.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.toDisplayItemList(): List = - this.map { it.toDisplayItem() } +private fun List.toDisplayItemList( + baseIconUrl: String, + isIconLoadingDisabled: Boolean, +): List = + 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 - } diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt index c0607c300..bbc6976b0 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultContent.kt @@ -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) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultEntryListItem.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultEntryListItem.kt index dbcbb5f5a..792796e1c 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultEntryListItem.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultEntryListItem.kt @@ -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 = {}, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt index 7df44a2c5..fc0bb5f50 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModel.kt @@ -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( 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) { 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) { 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.updateToErrorStateOrDialog( + baseIconUrl: String, vaultData: VaultData?, vaultFilterType: VaultFilterType, + isIconLoadingDisabled: Boolean, isPremium: Boolean, errorTitle: Text, errorMessage: Text, @@ -873,8 +914,10 @@ private fun MutableStateFlow.updateToErrorStateOrDialog( if (vaultData != null) { it.copy( viewState = vaultData.toViewState( + baseIconUrl = baseIconUrl, isPremium = isPremium, vaultFilterType = vaultFilterType, + isIconLoadingDisabled = isIconLoadingDisabled, ), dialog = VaultState.DialogState.Error( title = errorTitle, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt index 2948d3da0..88c22959b 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensions.kt @@ -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?.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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt index cb94b0a56..c7fa29410 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/repository/util/EnvironmentUrlsDataJsonExtensionsTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt index 422189517..9007af3de 100644 --- a/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/data/vault/datasource/sdk/model/CipherViewUtil.kt @@ -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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt index 049049b81..0d1590b7d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingScreenTest.kt @@ -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), ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt index 454df966a..6460c2033 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/VaultItemListingViewModelTest.kt @@ -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() + 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, ) } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt index 990dcd3dc..e16ac7b02 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataExtensionsTest.kt @@ -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() + 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 diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt index bf229ee42..cb9a856e3 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/itemlisting/util/VaultItemListingDataUtil.kt @@ -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), ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt index 5ea51ba53..51263e977 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultScreenTest.kt @@ -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( diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt index 22cb42c46..22fbe8451 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/VaultViewModelTest.kt @@ -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(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, ) diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt index 45e5862a6..5147d483d 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/vault/util/VaultDataExtensionsTest.kt @@ -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() + 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) + } }