From 61955d7cbe1546a4c467284b4a5491d4f0e47afa Mon Sep 17 00:00:00 2001 From: David Perez Date: Fri, 3 Apr 2026 09:55:48 -0500 Subject: [PATCH] PM-34544: bug: Handle large attachments in preview (#6757) --- .../vaultunlocked/VaultUnlockedNavigation.kt | 16 +----- .../attachments/AttachmentsNavigation.kt | 7 +-- .../feature/attachments/AttachmentsScreen.kt | 13 ++++- .../attachments/AttachmentsViewModel.kt | 5 ++ .../preview/PreviewAttachmentNavigation.kt | 16 +++--- .../preview/PreviewAttachmentScreen.kt | 27 ++++++++++ .../preview/PreviewAttachmentViewModel.kt | 50 +++++++++++++++-- .../util/AttachmentViewExtensions.kt | 15 ++++++ .../attachments/util/CipherViewExtensions.kt | 1 + .../vault/feature/item/VaultItemNavigation.kt | 7 +-- .../ui/vault/feature/item/VaultItemScreen.kt | 17 +++--- .../vault/feature/item/VaultItemViewModel.kt | 4 ++ .../item/component/VaultItemAttachment.kt | 18 ++++--- .../feature/item/util/CipherViewExtensions.kt | 7 +-- .../attachments/AttachmentsScreenTest.kt | 5 +- .../attachments/AttachmentsViewModelTest.kt | 3 ++ .../preview/PreviewAttachmentScreenTest.kt | 31 +++++++++++ .../preview/PreviewAttachmentViewModelTest.kt | 54 +++++++++++++++++++ .../util/CipherViewExtensionsTest.kt | 1 + .../vault/feature/item/VaultItemScreenTest.kt | 31 +++++------ .../dialog/BitwardenTwoButtonDialog.kt | 47 +++++++++++++++- ui/src/main/res/values/strings.xml | 4 +- 22 files changed, 304 insertions(+), 75 deletions(-) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/AttachmentViewExtensions.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt index fa2ed68994..70d622f807 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/vaultunlocked/VaultUnlockedNavigation.kt @@ -203,13 +203,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( passwordHistoryMode = GeneratorPasswordHistoryMode.Item(itemId = it), ) }, - onNavigateToPreviewAttachment = { cipherId, attachmentId, fileName -> - navController.navigateToPreviewAttachment( - cipherId = cipherId, - attachmentId = attachmentId, - fileName = fileName, - ) - }, + onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) }, ) vaultQrCodeScanDestination( onNavigateToManualCodeEntryScreen = { @@ -260,13 +254,7 @@ fun NavGraphBuilder.vaultUnlockedGraph( ) attachmentDestination( onNavigateBack = { navController.popBackStack() }, - onNavigateToPreviewAttachment = { cipherId, attachmentId, fileName -> - navController.navigateToPreviewAttachment( - cipherId = cipherId, - attachmentId = attachmentId, - fileName = fileName, - ) - }, + onNavigateToPreviewAttachment = { navController.navigateToPreviewAttachment(it) }, ) setupUnlockDestination( onNavigateBack = { diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsNavigation.kt index cecd920f81..9c981f01c5 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.toRoute import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions +import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.PreviewAttachmentRoute import kotlinx.serialization.Serializable /** @@ -34,11 +35,7 @@ fun SavedStateHandle.toAttachmentsArgs(): AttachmentsArgs { */ fun NavGraphBuilder.attachmentDestination( onNavigateBack: () -> Unit, - onNavigateToPreviewAttachment: ( - cipherId: String, - attachmentId: String, - fileName: String, - ) -> Unit, + onNavigateToPreviewAttachment: (route: PreviewAttachmentRoute) -> Unit, ) { composableWithSlideTransitions { AttachmentsScreen( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt index e28ebad76a..c49303ac99 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreen.kt @@ -32,6 +32,7 @@ import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.x8bit.bitwarden.ui.vault.feature.attachments.handlers.AttachmentsHandlers +import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.PreviewAttachmentRoute /** * Displays the attachments screen. @@ -43,7 +44,7 @@ fun AttachmentsScreen( viewModel: AttachmentsViewModel = hiltViewModel(), intentManager: IntentManager = LocalIntentManager.current, onNavigateBack: () -> Unit, - onNavigateToPreview: (cipherId: String, attachmentId: String, fileName: String) -> Unit, + onNavigateToPreview: (route: PreviewAttachmentRoute) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val attachmentsHandlers = remember(viewModel) { AttachmentsHandlers.create(viewModel) } @@ -65,7 +66,15 @@ fun AttachmentsScreen( is AttachmentsEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.data) is AttachmentsEvent.NavigateToPreview -> { - onNavigateToPreview(event.cipherId, event.attachmentId, event.fileName) + onNavigateToPreview( + PreviewAttachmentRoute( + cipherId = event.cipherId, + attachmentId = event.attachmentId, + fileName = event.fileName, + displaySize = event.displaySize, + isLargeFile = event.isLargeFile, + ), + ) } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt index 5e09efd51c..fcffd23c04 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModel.kt @@ -247,6 +247,8 @@ class AttachmentsViewModel @Inject constructor( cipherId = state.cipherId, attachmentId = action.attachment.id, fileName = action.attachment.title, + displaySize = action.attachment.displaySize, + isLargeFile = action.attachment.isLargeFile, ), ) } @@ -474,6 +476,7 @@ data class AttachmentsState( val id: String, val title: String, val displaySize: String, + val isLargeFile: Boolean, ) : Parcelable /** @@ -527,6 +530,8 @@ sealed class AttachmentsEvent { val cipherId: String, val attachmentId: String, val fileName: String, + val displaySize: String, + val isLargeFile: Boolean, ) : AttachmentsEvent() /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentNavigation.kt index 9b596ee246..1c0f96f40b 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentNavigation.kt @@ -16,6 +16,8 @@ data class PreviewAttachmentRoute( val cipherId: String, val attachmentId: String, val fileName: String, + val displaySize: String, + val isLargeFile: Boolean, ) /** @@ -25,6 +27,8 @@ data class PreviewAttachmentArgs( val cipherId: String, val attachmentId: String, val fileName: String, + val displaySize: String, + val isLargeFile: Boolean, ) /** @@ -36,6 +40,8 @@ fun SavedStateHandle.toPreviewAttachmentArgs(): PreviewAttachmentArgs { cipherId = route.cipherId, attachmentId = route.attachmentId, fileName = route.fileName, + displaySize = route.displaySize, + isLargeFile = route.isLargeFile, ) } @@ -56,17 +62,11 @@ fun NavGraphBuilder.previewAttachmentDestination( * Navigate to the preview attachment screen. */ fun NavController.navigateToPreviewAttachment( - cipherId: String, - attachmentId: String, - fileName: String, + route: PreviewAttachmentRoute, navOptions: NavOptions? = null, ) { navigate( - route = PreviewAttachmentRoute( - cipherId = cipherId, - attachmentId = attachmentId, - fileName = fileName, - ), + route = route, navOptions = navOptions, ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreen.kt index 5eb174baeb..35371e5fda 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreen.kt @@ -11,6 +11,8 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.bitwarden.ui.platform.base.util.EventsEffect +import com.bitwarden.ui.platform.base.util.annotatedStringResource +import com.bitwarden.ui.platform.base.util.spanStyleOf import com.bitwarden.ui.platform.components.appbar.BitwardenTopAppBar import com.bitwarden.ui.platform.components.appbar.NavigationIcon import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton @@ -19,6 +21,7 @@ import com.bitwarden.ui.platform.components.content.BitwardenErrorContent import com.bitwarden.ui.platform.components.content.BitwardenLoadingContent import com.bitwarden.ui.platform.components.dialog.BitwardenBasicDialog import com.bitwarden.ui.platform.components.dialog.BitwardenLoadingDialog +import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.icon.model.IconData import com.bitwarden.ui.platform.components.preview.ImagePreviewContent import com.bitwarden.ui.platform.components.scaffold.BitwardenScaffold @@ -29,6 +32,7 @@ import com.bitwarden.ui.platform.composition.LocalIntentManager import com.bitwarden.ui.platform.manager.IntentManager import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.platform.theme.BitwardenTheme import com.bitwarden.ui.util.asText /** @@ -65,6 +69,9 @@ fun PreviewAttachmentScreen( dialogState = state.dialogState, onDismissRequest = { viewModel.trySendAction(PreviewAttachmentAction.DismissDialog) }, onCloseClick = { viewModel.trySendAction(PreviewAttachmentAction.CloseClick) }, + onConfirmDownloadClick = { + viewModel.trySendAction(PreviewAttachmentAction.ConfirmDownloadClick) + }, ) BitwardenScaffold( @@ -139,6 +146,7 @@ private fun PreviewAttachmentDialogs( dialogState: PreviewAttachmentState.DialogState?, onDismissRequest: () -> Unit, onCloseClick: () -> Unit, + onConfirmDownloadClick: () -> Unit, ) { when (dialogState) { is PreviewAttachmentState.DialogState.Error -> { @@ -165,6 +173,25 @@ private fun PreviewAttachmentDialogs( ) } + is PreviewAttachmentState.DialogState.DownloadLargeFileConfirmation -> { + BitwardenTwoButtonDialog( + title = stringResource(id = BitwardenString.download_attachment), + message = annotatedStringResource( + id = BitwardenString.attachment_large_warning, + args = arrayOf(dialogState.displaySize), + style = spanStyleOf( + color = BitwardenTheme.colorScheme.text.primary, + textStyle = BitwardenTheme.typography.bodyMedium, + ), + ), + confirmButtonText = stringResource(id = BitwardenString.yes), + dismissButtonText = stringResource(id = BitwardenString.no), + onConfirmClick = onConfirmDownloadClick, + onDismissClick = onDismissRequest, + onDismissRequest = onDismissRequest, + ) + } + null -> Unit } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModel.kt index 8879dca893..9996475e86 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModel.kt @@ -53,19 +53,27 @@ class PreviewAttachmentViewModel @Inject constructor( initialState = savedStateHandle[KEY_STATE] ?: run { val args = savedStateHandle.toPreviewAttachmentArgs() val isPreviewable = args.fileName.isPreviewable + val isLargeFile = args.isLargeFile PreviewAttachmentState( cipherId = args.cipherId, attachmentId = args.attachmentId, fileName = args.fileName, + displaySize = args.displaySize, + isLargeFile = isLargeFile, isPreviewable = isPreviewable, - viewState = if (isPreviewable) { - PreviewAttachmentState.ViewState.Loading() - } else { + viewState = if (!isPreviewable) { PreviewAttachmentState.ViewState.Error( message = BitwardenString .preview_not_available_for_files .asText(args.fileName.fileExtension), ) + } else if (isLargeFile) { + PreviewAttachmentState.ViewState.Error( + message = BitwardenString.this_file_is_too_large_to_preview.asText(), + illustrationRes = BitwardenDrawable.ill_file_error, + ) + } else { + PreviewAttachmentState.ViewState.Loading() }, dialogState = null, ) @@ -88,7 +96,7 @@ class PreviewAttachmentViewModel @Inject constructor( refreshDataFlow .filter { // Don't bother retrieving the file is we cannot display it. - state.isPreviewable + state.isPreviewable && !state.isLargeFile } .flatMapLatest { vaultRepository @@ -106,6 +114,7 @@ class PreviewAttachmentViewModel @Inject constructor( PreviewAttachmentAction.CloseClick -> handleCloseClick() PreviewAttachmentAction.DismissDialog -> handleDismissDialog() PreviewAttachmentAction.DownloadClick -> handleDownloadClick() + PreviewAttachmentAction.ConfirmDownloadClick -> handleConfirmDownloadClick() PreviewAttachmentAction.BitmapRenderComplete -> handleBitmapRenderComplete() PreviewAttachmentAction.BitmapRenderError -> handleBitmapRenderError() PreviewAttachmentAction.FileMissing -> handleFileMissing() @@ -316,6 +325,24 @@ class PreviewAttachmentViewModel @Inject constructor( } private fun handleDownloadClick() { + if (state.isLargeFile) { + mutableStateFlow.update { + it.copy( + dialogState = PreviewAttachmentState.DialogState.DownloadLargeFileConfirmation( + displaySize = state.displaySize, + ), + ) + } + return + } + downloadFile() + } + + private fun handleConfirmDownloadClick() { + downloadFile() + } + + private fun downloadFile() { mutableStateFlow.update { it.copy( dialogState = PreviewAttachmentState.DialogState.Loading( @@ -390,6 +417,8 @@ data class PreviewAttachmentState( val cipherId: String, val attachmentId: String, val fileName: String, + val displaySize: String, + val isLargeFile: Boolean, val isPreviewable: Boolean, val viewState: ViewState, val dialogState: DialogState?, @@ -452,6 +481,14 @@ data class PreviewAttachmentState( */ @Parcelize data object PreviewUnavailable : DialogState() + + /** + * Represents a warning dialog when the user is about to download a large file. + */ + @Parcelize + data class DownloadLargeFileConfirmation( + val displaySize: String, + ) : DialogState() } } @@ -517,6 +554,11 @@ sealed class PreviewAttachmentAction { */ data object DownloadClick : PreviewAttachmentAction() + /** + * User clicked to confirm that they want to download the file. + */ + data object ConfirmDownloadClick : PreviewAttachmentAction() + /** * The bitmap has been rendered from file. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/AttachmentViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/AttachmentViewExtensions.kt new file mode 100644 index 0000000000..54dcfdac2a --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/AttachmentViewExtensions.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.ui.vault.feature.attachments.util + +import com.bitwarden.vault.AttachmentView + +private const val TEN_MB_IN_BYTES: Long = 10485760L + +/** + * @return `true` if the file is larger than 10MB, `false` otherwise. + */ +fun AttachmentView.isLargeFile(): Boolean = + try { + (this.size?.toLong() ?: 0L) >= TEN_MB_IN_BYTES + } catch (_: NumberFormatException) { + false + } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt index be0c8a02a7..f78c6b3dd9 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensions.kt @@ -19,6 +19,7 @@ fun CipherView.toViewState(): AttachmentsState.ViewState.Content = id = id, title = it.fileName.orEmpty(), displaySize = it.sizeName.orEmpty(), + isLargeFile = it.isLargeFile(), ) } .toImmutableList(), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt index ef6e0549e2..3f7aa2b534 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.NavOptions import androidx.navigation.toRoute import com.bitwarden.ui.platform.base.util.composableWithSlideTransitions import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs +import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.PreviewAttachmentRoute import com.x8bit.bitwarden.ui.vault.model.VaultItemCipherType import kotlinx.serialization.Serializable @@ -45,11 +46,7 @@ fun NavGraphBuilder.vaultItemDestination( onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, - onNavigateToPreviewAttachment: ( - cipherId: String, - attachmentId: String, - fileName: String, - ) -> Unit, + onNavigateToPreviewAttachment: (route: PreviewAttachmentRoute) -> Unit, ) { composableWithSlideTransitions { VaultItemScreen( diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt index 84c7cf48a6..d078674a53 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreen.kt @@ -42,6 +42,7 @@ import com.bitwarden.ui.platform.resource.BitwardenDrawable import com.bitwarden.ui.platform.resource.BitwardenString import com.bitwarden.ui.util.asText import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs +import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.PreviewAttachmentRoute import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCardItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultCommonItemTypeHandlers import com.x8bit.bitwarden.ui.vault.feature.item.handlers.VaultIdentityItemTypeHandlers @@ -63,11 +64,7 @@ fun VaultItemScreen( onNavigateToMoveToOrganization: (vaultItemId: String, showOnlyCollections: Boolean) -> Unit, onNavigateToAttachments: (vaultItemId: String) -> Unit, onNavigateToPasswordHistory: (vaultItemId: String) -> Unit, - onNavigateToPreviewAttachment: ( - cipherId: String, - attachmentId: String, - fileName: String, - ) -> Unit, + onNavigateToPreviewAttachment: (route: PreviewAttachmentRoute) -> Unit, ) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() val fileChooserLauncher = intentManager.getActivityResultLauncher { activityResult -> @@ -122,7 +119,15 @@ fun VaultItemScreen( } is VaultItemEvent.NavigateToPreviewAttachment -> { - onNavigateToPreviewAttachment(event.cipherId, event.attachmentId, event.fileName) + onNavigateToPreviewAttachment( + PreviewAttachmentRoute( + cipherId = event.cipherId, + attachmentId = event.attachmentId, + fileName = event.fileName, + displaySize = event.displaySize, + isLargeFile = event.isLargeFile, + ), + ) } } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt index 0256661317..acf2e02b62 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemViewModel.kt @@ -423,6 +423,8 @@ class VaultItemViewModel @Inject constructor( cipherId = state.vaultItemId, attachmentId = action.attachment.id, fileName = action.attachment.title, + displaySize = action.attachment.displaySize, + isLargeFile = action.attachment.isLargeFile, ), ) } @@ -2008,6 +2010,8 @@ sealed class VaultItemEvent { val cipherId: String, val attachmentId: String, val fileName: String, + val displaySize: String, + val isLargeFile: Boolean, ) : VaultItemEvent() /** diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemAttachment.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemAttachment.kt index c76bf28988..2180f001ba 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemAttachment.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/VaultItemAttachment.kt @@ -20,7 +20,9 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.bitwarden.ui.platform.base.util.annotatedStringResource import com.bitwarden.ui.platform.base.util.cardStyle +import com.bitwarden.ui.platform.base.util.spanStyleOf import com.bitwarden.ui.platform.components.button.BitwardenStandardIconButton import com.bitwarden.ui.platform.components.dialog.BitwardenTwoButtonDialog import com.bitwarden.ui.platform.components.model.CardStyle @@ -57,10 +59,6 @@ fun VaultItemAttachment( shouldShowPremiumWarningDialog = true return@cardStyle } - if (attachmentItem.isLargeFile) { - shouldShowSizeWarningDialog = true - return@cardStyle - } onAttachmentPreviewClick(attachmentItem) }, ) @@ -138,10 +136,14 @@ fun VaultItemAttachment( if (shouldShowSizeWarningDialog) { BitwardenTwoButtonDialog( - title = null, - message = stringResource( - BitwardenString.attachment_large_warning, - attachmentItem.displaySize, + title = stringResource(id = BitwardenString.download_attachment), + message = annotatedStringResource( + id = BitwardenString.attachment_large_warning, + args = arrayOf(attachmentItem.displaySize), + style = spanStyleOf( + color = BitwardenTheme.colorScheme.text.primary, + textStyle = BitwardenTheme.typography.bodyMedium, + ), ), confirmButtonText = stringResource(BitwardenString.yes), dismissButtonText = stringResource(BitwardenString.no), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt index 91527229c0..811ff61a60 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/util/CipherViewExtensions.kt @@ -19,6 +19,7 @@ import com.bitwarden.vault.FieldView import com.bitwarden.vault.IdentityView import com.bitwarden.vault.LoginUriView import com.x8bit.bitwarden.data.vault.repository.model.VaultData +import com.x8bit.bitwarden.ui.vault.feature.attachments.util.isLargeFile import com.x8bit.bitwarden.ui.vault.feature.item.VaultItemState import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation @@ -97,11 +98,7 @@ fun CipherView.toViewState( title = requireNotNull(it.fileName), displaySize = requireNotNull(it.sizeName), url = requireNotNull(it.url), - isLargeFile = try { - requireNotNull(it.size).toLong() >= 10485760 - } catch (_: NumberFormatException) { - false - }, + isLargeFile = it.isLargeFile(), isDownloadAllowed = isPremiumUser || this.organizationId != null, ) } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt index c1fde06aa1..f0b1b5d712 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsScreenTest.kt @@ -54,7 +54,7 @@ class AttachmentsScreenTest : BitwardenComposeTest() { AttachmentsScreen( viewModel = viewModel, onNavigateBack = { onNavigateBackCalled = true }, - onNavigateToPreview = { _, _, _ -> onNavigateToPreviewCalled = true }, + onNavigateToPreview = { onNavigateToPreviewCalled = true }, ) } } @@ -81,6 +81,8 @@ class AttachmentsScreenTest : BitwardenComposeTest() { cipherId = "cipherId", attachmentId = "attachmentId", fileName = "file.png", + displaySize = "10 MB", + isLargeFile = true, ), ) assertTrue(onNavigateToPreviewCalled) @@ -337,6 +339,7 @@ private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content id = "cipherId-1234", title = "cool_file.png", displaySize = "10 MB", + isLargeFile = true, ), ), newAttachment = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt index 5c4ed7348e..55d9c66f1a 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/AttachmentsViewModelTest.kt @@ -137,6 +137,8 @@ class AttachmentsViewModelTest : BaseViewModelTest() { cipherId = DEFAULT_STATE.cipherId, attachmentId = DEFAULT_ATTACHMENT_ITEM.id, fileName = DEFAULT_ATTACHMENT_ITEM.title, + displaySize = DEFAULT_ATTACHMENT_ITEM.displaySize, + isLargeFile = false, ), awaitItem(), ) @@ -850,6 +852,7 @@ private val DEFAULT_ATTACHMENT_ITEM: AttachmentsState.AttachmentItem = id = "mockId-1", title = "mockFileName-1", displaySize = "mockSizeName-1", + isLargeFile = false, ) private val DEFAULT_CONTENT_WITH_ATTACHMENTS: AttachmentsState.ViewState.Content = diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreenTest.kt index ddc3a70582..74061ee8f9 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentScreenTest.kt @@ -247,6 +247,35 @@ class PreviewAttachmentScreenTest : BitwardenComposeTest() { } } + @Test + fun `confirm download dialog should display title and message`() { + composeTestRule.assertNoDialogExists() + + mutableStateFlow.update { + it.copy( + dialogState = PreviewAttachmentState.DialogState.DownloadLargeFileConfirmation( + displaySize = "2.89 MB", + ), + ) + } + + composeTestRule + .onNodeWithText(text = "Download Attachment") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "This file is 2.89 MB. Would you like to download it?") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(text = "Yes") + .assert(hasAnyAncestor(isDialog())) + .performClick() + verify(exactly = 1) { + viewModel.trySendAction(PreviewAttachmentAction.ConfirmDownloadClick) + } + } + @Test fun `NavigateToSelectAttachmentSaveLocation event should launch file chooser`() { val fileName = "test.png" @@ -265,6 +294,8 @@ private val DEFAULT_STATE = PreviewAttachmentState( cipherId = "mockCipherId", attachmentId = "mockAttachmentId", fileName = DEFAULT_FILE_NAME, + displaySize = "2.89 MB", + isLargeFile = false, isPreviewable = true, viewState = PreviewAttachmentState.ViewState.Loading(), dialogState = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModelTest.kt index c3cfccdad0..3cd3b02b2c 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModelTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/preview/PreviewAttachmentViewModelTest.kt @@ -304,6 +304,53 @@ class PreviewAttachmentViewModelTest : BaseViewModelTest() { coVerify(exactly = 1) { fileManager.delete(mockFile) } } + @Suppress("MaxLineLength") + @Test + fun `ConfirmDownloadClick should show loading dialog and emit NavigateToSelectAttachmentSaveLocation`() = + runTest { + val cipherView = createMockCipherView(number = 1) + mutableVaultItemStateFlow.value = DataState.Loaded(cipherView) + coEvery { + vaultRepository.downloadAttachment( + cipherView = cipherView, + attachmentId = DEFAULT_ATTACHMENT_ID, + ) + } returns DownloadAttachmentResult.Success(mockFile) + val viewModel = createViewModel(initialState = NON_PREVIEWABLE_STATE) + viewModel.eventFlow.test { + viewModel.trySendAction(PreviewAttachmentAction.ConfirmDownloadClick) + assertEquals( + PreviewAttachmentEvent.NavigateToSelectAttachmentSaveLocation( + fileName = NON_PREVIEWABLE_STATE.fileName, + ), + awaitItem(), + ) + } + } + + @Test + fun `DownloadClick should show confirmation dialog when file is large`() = runTest { + val cipherView = createMockCipherView(number = 1) + mutableVaultItemStateFlow.value = DataState.Loaded(cipherView) + coEvery { + vaultRepository.downloadAttachment( + cipherView = cipherView, + attachmentId = DEFAULT_ATTACHMENT_ID, + ) + } returns DownloadAttachmentResult.Success(mockFile) + val viewModel = createViewModel(initialState = DEFAULT_STATE.copy(isLargeFile = true)) + viewModel.trySendAction(PreviewAttachmentAction.DownloadClick) + assertEquals( + DEFAULT_STATE.copy( + dialogState = PreviewAttachmentState.DialogState.DownloadLargeFileConfirmation( + displaySize = DEFAULT_STATE.displaySize, + ), + isLargeFile = true, + ), + viewModel.stateFlow.value, + ) + } + @Suppress("MaxLineLength") @Test fun `DownloadClick should show loading dialog and emit NavigateToSelectAttachmentSaveLocation`() = @@ -857,6 +904,8 @@ class PreviewAttachmentViewModelTest : BaseViewModelTest() { cipherId = initialState?.cipherId ?: DEFAULT_CIPHER_ID, attachmentId = initialState?.attachmentId ?: DEFAULT_ATTACHMENT_ID, fileName = initialState?.fileName ?: DEFAULT_FILE_NAME, + displaySize = "2.89 MB", + isLargeFile = false, ) }, ) @@ -865,11 +914,14 @@ class PreviewAttachmentViewModelTest : BaseViewModelTest() { private const val DEFAULT_CIPHER_ID = "mockCipherId" private const val DEFAULT_ATTACHMENT_ID = "mockAttachmentId" private const val DEFAULT_FILE_NAME = "test.png" +private const val DEFAULT_DISPLAY_SIZE = "2.89 MB" private val DEFAULT_STATE = PreviewAttachmentState( cipherId = DEFAULT_CIPHER_ID, attachmentId = DEFAULT_ATTACHMENT_ID, fileName = DEFAULT_FILE_NAME, + displaySize = DEFAULT_DISPLAY_SIZE, + isLargeFile = false, isPreviewable = true, viewState = PreviewAttachmentState.ViewState.Loading(), dialogState = null, @@ -879,7 +931,9 @@ private val NON_PREVIEWABLE_STATE = PreviewAttachmentState( cipherId = DEFAULT_CIPHER_ID, attachmentId = DEFAULT_ATTACHMENT_ID, fileName = "test.pdf", + displaySize = DEFAULT_DISPLAY_SIZE, isPreviewable = false, + isLargeFile = false, viewState = PreviewAttachmentState.ViewState.Error( message = BitwardenString.preview_not_available_for_files.asText("PDF"), ), diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt index ad03c2bd1a..7bc3d3c089 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/attachments/util/CipherViewExtensionsTest.kt @@ -23,6 +23,7 @@ class CipherViewExtensionsTest { id = "mockId-1", title = "mockFileName-1", displaySize = "mockSizeName-1", + isLargeFile = false, ), ), newAttachment = null, diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt index 3d33efc5cc..e091bfe9a4 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemScreenTest.kt @@ -41,6 +41,7 @@ import com.bitwarden.ui.util.onNodeWithTextAfterScroll import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView import com.x8bit.bitwarden.ui.platform.base.BitwardenComposeTest import com.x8bit.bitwarden.ui.vault.feature.addedit.VaultAddEditArgs +import com.x8bit.bitwarden.ui.vault.feature.attachments.preview.PreviewAttachmentRoute import com.x8bit.bitwarden.ui.vault.feature.item.model.TotpCodeItemData import com.x8bit.bitwarden.ui.vault.feature.item.model.VaultItemLocation import com.x8bit.bitwarden.ui.vault.model.VaultAddEditType @@ -69,7 +70,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { private var onNavigateToMoveToOrganizationItemId: String? = null private var onNavigateToAttachmentsId: String? = null private var onNavigateToPasswordHistoryId: String? = null - private var onNavigateToPreviewAttachmentId: String? = null + private var onNavigateToPreviewAttachment: PreviewAttachmentRoute? = null private val intentManager = mockk(relaxed = true) @@ -94,9 +95,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { }, onNavigateToAttachments = { onNavigateToAttachmentsId = it }, onNavigateToPasswordHistory = { onNavigateToPasswordHistoryId = it }, - onNavigateToPreviewAttachment = { id, _, _ -> - onNavigateToPreviewAttachmentId = id - }, + onNavigateToPreviewAttachment = { onNavigateToPreviewAttachment = it }, ) } } @@ -144,15 +143,23 @@ class VaultItemScreenTest : BitwardenComposeTest() { @Test fun `NavigateToPreviewAttachment event should invoke onNavigateToPreviewAttachment`() { - val cipherId = "cipherId1234" + val route = PreviewAttachmentRoute( + cipherId = "cipherId1234", + attachmentId = "attachmentId4321", + fileName = "fileName", + displaySize = "2.89 MB", + isLargeFile = false, + ) mutableEventFlow.tryEmit( VaultItemEvent.NavigateToPreviewAttachment( - cipherId = cipherId, + cipherId = "cipherId1234", attachmentId = "attachmentId4321", fileName = "fileName", + displaySize = "2.89 MB", + isLargeFile = false, ), ) - assertEquals(cipherId, onNavigateToPreviewAttachmentId) + assertEquals(route, onNavigateToPreviewAttachment) } @Test @@ -831,10 +838,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { .performClick() composeTestRule - .onAllNodesWithText( - "This attachment is 11 MB in size. Are you sure you want to download it onto " + - "your device?", - ) + .onAllNodesWithText("This file is 11 MB. Would you like to download it?") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() @@ -873,10 +877,7 @@ class VaultItemScreenTest : BitwardenComposeTest() { .performClick() composeTestRule - .onAllNodesWithText( - "This attachment is 11 MB in size. Are you sure you want to download it onto " + - "your device?", - ) + .onAllNodesWithText("This file is 11 MB. Would you like to download it?") .filterToOne(hasAnyAncestor(isDialog())) .assertIsDisplayed() diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt index 5292dde900..5592258914 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/dialog/BitwardenTwoButtonDialog.kt @@ -22,9 +22,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import com.bitwarden.ui.platform.base.util.toAnnotatedString import com.bitwarden.ui.platform.components.button.BitwardenTextButton import com.bitwarden.ui.platform.components.dialog.util.maxDialogHeight import com.bitwarden.ui.platform.components.dialog.util.maxDialogWidth @@ -46,7 +48,6 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme * @param dismissTextColor The color of the dismiss text. */ @Composable -@Suppress("LongMethod") fun BitwardenTwoButtonDialog( title: String?, message: String, @@ -59,6 +60,50 @@ fun BitwardenTwoButtonDialog( dismissTextColor: Color = BitwardenTheme.colorScheme.outlineButton.foreground, dismissOnBackPress: Boolean = true, dismissOnClickOutside: Boolean = true, +) { + BitwardenTwoButtonDialog( + title = title, + message = message.toAnnotatedString(), + confirmButtonText = confirmButtonText, + dismissButtonText = dismissButtonText, + onConfirmClick = onConfirmClick, + onDismissClick = onDismissClick, + onDismissRequest = onDismissRequest, + confirmTextColor = confirmTextColor, + dismissTextColor = dismissTextColor, + dismissOnBackPress = dismissOnBackPress, + dismissOnClickOutside = dismissOnClickOutside, + ) +} + +/** + * Represents a Bitwarden-styled dialog with two buttons. + * + * @param title the optional title to show. + * @param message message to show. + * @param confirmButtonText text to show on confirm button. + * @param dismissButtonText text to show on dismiss button. + * @param onConfirmClick called when the confirm button is clicked. + * @param onDismissClick called when the dismiss button is clicked. + * @param onDismissRequest called when the user attempts to dismiss the dialog (for example by + * tapping outside of it). + * @param confirmTextColor The color of the confirm text. + * @param dismissTextColor The color of the dismiss text. + */ +@Composable +@Suppress("LongMethod") +fun BitwardenTwoButtonDialog( + title: String?, + message: AnnotatedString, + confirmButtonText: String, + dismissButtonText: String, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, + onDismissRequest: () -> Unit, + confirmTextColor: Color = BitwardenTheme.colorScheme.outlineButton.foreground, + dismissTextColor: Color = BitwardenTheme.colorScheme.outlineButton.foreground, + dismissOnBackPress: Boolean = true, + dismissOnClickOutside: Boolean = true, ) { Dialog( onDismissRequest = onDismissRequest, diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 5fc7b0612e..1e54cff32b 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -189,7 +189,7 @@ Attachments Unable to download file. Downloading… - This attachment is %1$s in size. Are you sure you want to download it onto your device? + This file is %1$s. Would you like to download it? Authenticator key Verification code (TOTP) Authenticator key added. @@ -1231,9 +1231,11 @@ Do you want to switch to this account? External link %1$s, External link Preview unavailable for %1$s files. You can still download it to view on your device. + This file is too large to preview. You can still download it to view on your device. Bitwarden could not decrypt this file, so the preview cannot be displayed. Preview unavailable for this file. You can still download it to view on your device. Preview unavailable + Download Attachment Upgraded to premium Unlock more advanced features with a Premium plan. / month