diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt index fcb1fed004f..c529c8af664 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemAttachmentContent.kt @@ -34,6 +34,7 @@ import com.bitwarden.ui.platform.theme.BitwardenTheme fun AttachmentItemContent( attachmentItem: VaultItemState.ViewState.Content.Common.AttachmentItem, onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, + onAttachmentPreviewClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, cardStyle: CardStyle, modifier: Modifier = Modifier, ) { @@ -42,7 +43,7 @@ fun AttachmentItemContent( Row( modifier = modifier .defaultMinSize(minHeight = 60.dp) - .cardStyle(cardStyle = cardStyle, paddingStart = 16.dp) + .cardStyle(cardStyle = cardStyle, paddingStart = 16.dp, paddingEnd = 8.dp) .testTag("CipherAttachment"), verticalAlignment = Alignment.CenterVertically, ) { @@ -69,6 +70,22 @@ fun AttachmentItemContent( Spacer(modifier = Modifier.width(8.dp)) + if (attachmentItem.isPreviewable) { + BitwardenStandardIconButton( + vectorIconRes = BitwardenDrawable.ic_preview, + contentDescription = stringResource(id = BitwardenString.preview), + onClick = { + if (!attachmentItem.isDownloadAllowed) { + shouldShowPremiumWarningDialog = true + return@BitwardenStandardIconButton + } + onAttachmentPreviewClick(attachmentItem) + }, + modifier = Modifier + .testTag("AttachmentPreviewButton"), + ) + } + BitwardenStandardIconButton( vectorIconRes = BitwardenDrawable.ic_download, contentDescription = stringResource(id = BitwardenString.download), diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt index 565cd88e2ac..f3a97170356 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemCardContent.kt @@ -299,6 +299,8 @@ fun VaultItemCardContent( attachmentItem = attachmentItem, onAttachmentDownloadClick = vaultCommonItemTypeHandlers .onAttachmentDownloadClick, + onAttachmentPreviewClick = vaultCommonItemTypeHandlers + .onAttachmentPreviewClick, cardStyle = attachments.toListItemCardStyle(index = index), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt index 1ccf7a47d07..7563048b14e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemIdentityContent.kt @@ -351,6 +351,8 @@ fun VaultItemIdentityContent( attachmentItem = attachmentItem, onAttachmentDownloadClick = vaultCommonItemTypeHandlers .onAttachmentDownloadClick, + onAttachmentPreviewClick = vaultCommonItemTypeHandlers + .onAttachmentPreviewClick, cardStyle = attachments.toListItemCardStyle(index = index), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt index 7811236f0e5..3b641507b5a 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemLoginContent.kt @@ -265,7 +265,9 @@ fun VaultItemLoginContent( cardStyle = attachments.toListItemCardStyle(index = index), onAttachmentDownloadClick = vaultCommonItemTypeHandlers .onAttachmentDownloadClick, - ) + onAttachmentPreviewClick = vaultCommonItemTypeHandlers + .onAttachmentPreviewClick, + ) } } 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 faaaef75eaa..ae52bc620e0 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 @@ -40,6 +40,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.addedit.VaultAddEditArgs +import com.x8bit.bitwarden.ui.vault.feature.item.component.AttachmentPreviewDialog 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 @@ -121,6 +122,9 @@ fun VaultItemScreen( onDismissRequest = remember(viewModel) { { viewModel.trySendAction(VaultItemAction.Common.DismissDialogClick) } }, + onPreviewLoaded = remember(viewModel) { + { viewModel.trySendAction(VaultItemAction.Internal.AttachmentPreviewLoaded) } + }, onConfirmDeleteClick = remember(viewModel) { { viewModel.trySendAction(VaultItemAction.Common.ConfirmDeleteClick) } }, @@ -274,6 +278,7 @@ fun VaultItemScreen( private fun VaultItemDialogs( dialog: VaultItemState.DialogState?, onDismissRequest: () -> Unit, + onPreviewLoaded: () -> Unit, onConfirmDeleteClick: () -> Unit, onConfirmCloneWithoutFido2Credential: () -> Unit, onConfirmRestoreAction: () -> Unit, @@ -324,6 +329,14 @@ private fun VaultItemDialogs( onDismissRequest = onDismissRequest, ) + is VaultItemState.DialogState.AttachmentPreview -> { + AttachmentPreviewDialog( + attachmentFile = dialog.file, + onDismissRequest = onDismissRequest, + onLoaded = onPreviewLoaded + ) + } + null -> Unit } } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt index 3517632a07e..3d14bfa06f4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSecureNoteContent.kt @@ -147,6 +147,8 @@ fun VaultItemSecureNoteContent( attachmentItem = attachmentItem, onAttachmentDownloadClick = vaultCommonItemTypeHandlers .onAttachmentDownloadClick, + onAttachmentPreviewClick = vaultCommonItemTypeHandlers + .onAttachmentPreviewClick, cardStyle = attachments.toListItemCardStyle(index = index), ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt index b60f0b46621..e3cd9919ca3 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/VaultItemSshKeyContent.kt @@ -232,6 +232,8 @@ fun VaultItemSshKeyContent( attachmentItem = attachmentItem, onAttachmentDownloadClick = vaultCommonItemTypeHandlers .onAttachmentDownloadClick, + onAttachmentPreviewClick = vaultCommonItemTypeHandlers + .onAttachmentPreviewClick, cardStyle = attachments.toListItemCardStyle(index = index), ) } 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 088228e1aba..0b012b451ad 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 @@ -255,6 +255,10 @@ class VaultItemViewModel @Inject constructor( handleAttachmentDownloadClick(action) } + is VaultItemAction.Common.AttachmentPreviewClick -> { + handleAttachmentPreviewClick(action) + } + is VaultItemAction.Common.AttachmentFileLocationReceive -> { handleAttachmentFileLocationReceive(action) } @@ -378,6 +382,33 @@ class VaultItemViewModel @Inject constructor( VaultItemAction.Internal.AttachmentDecryptReceive( result = result, fileName = action.attachment.title, + isPreview = false + ), + ) + } + } + } + + private fun handleAttachmentPreviewClick( + action: VaultItemAction.Common.AttachmentPreviewClick, + ) { + onContent { content -> + updateDialogState( + VaultItemState.DialogState.Loading(BitwardenString.loading.asText()), + ) + + viewModelScope.launch { + val result = vaultRepository + .downloadAttachment( + cipherView = requireNotNull(content.common.currentCipher), + attachmentId = action.attachment.id, + ) + + trySendAction( + VaultItemAction.Internal.AttachmentDecryptReceive( + result = result, + fileName = action.attachment.title, + isPreview = true ), ) } @@ -952,6 +983,10 @@ class VaultItemViewModel @Inject constructor( handleAttachmentFinishedSavingToDisk(action) } + is VaultItemAction.Internal.AttachmentPreviewLoaded -> { + handleAttachmentPreviewLoaded() + } + is VaultItemAction.Internal.IsIconLoadingDisabledUpdateReceive -> { handleIsIconLoadingDisabledUpdateReceive(action) } @@ -1142,11 +1177,15 @@ class VaultItemViewModel @Inject constructor( is DownloadAttachmentResult.Success -> { temporaryAttachmentData = result.file - sendEvent( - VaultItemEvent.NavigateToSelectAttachmentSaveLocation( - fileName = action.fileName, - ), - ) + if (action.isPreview) { + updateDialogState(VaultItemState.DialogState.AttachmentPreview(result.file)) + } else { + sendEvent( + VaultItemEvent.NavigateToSelectAttachmentSaveLocation( + fileName = action.fileName, + ), + ) + } } } } @@ -1169,6 +1208,12 @@ class VaultItemViewModel @Inject constructor( } } + private fun handleAttachmentPreviewLoaded() { + viewModelScope.launch { + temporaryAttachmentData?.let { fileManager.delete(it) } + } + } + private fun handleRestoreItemClicked() { updateDialogState(VaultItemState.DialogState.RestoreItemDialog) } @@ -1425,6 +1470,7 @@ data class VaultItemState( val url: String, val isLargeFile: Boolean, val isDownloadAllowed: Boolean, + val isPreviewable: Boolean, ) : Parcelable /** @@ -1727,6 +1773,12 @@ data class VaultItemState( */ @Parcelize data object RestoreItemDialog : DialogState() + + /** + * Displays a preview of an image attachment. + */ + @Parcelize + data class AttachmentPreview(val file: File) : DialogState() } } @@ -1913,6 +1965,13 @@ sealed class VaultItemAction { val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem, ) : Common() + /** + * The user has clicked the preview button. + */ + data class AttachmentPreviewClick( + val attachment: VaultItemState.ViewState.Content.Common.AttachmentItem, + ) : Common() + /** * The user has selected a location to save the file. */ @@ -2153,6 +2212,7 @@ sealed class VaultItemAction { data class AttachmentDecryptReceive( val result: DownloadAttachmentResult, val fileName: String, + val isPreview: Boolean, ) : Internal() /** @@ -2164,6 +2224,12 @@ sealed class VaultItemAction { val file: File, ) : Internal() + /** + * Indicates the attachment file has been loaded into memory and the + * temporary file on disk can be deleted. + */ + data object AttachmentPreviewLoaded : Internal() + /** * Indicates the `isIconLoadingDisabled` setting has changed. */ diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/AttachmentPreviewDialog.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/AttachmentPreviewDialog.kt new file mode 100644 index 00000000000..db8b74b859b --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/component/AttachmentPreviewDialog.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.ui.vault.feature.item.component + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.bitwarden.ui.platform.resource.BitwardenString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Displays a secure, temporary image attachment in a full-screen dialog with zoom and pan + * capabilities. The dialog can be dismissed by clicking the close button or pressing the back button. + * + * @param attachmentFile The temporary [File] object representing the decrypted image. + * @param onDismissRequest A lambda to be invoked when the dialog is dismissed. + * @param onLoaded A security-critical callback invoked once the [attachmentFile] has been read + * from disk. It signals that the temporary file can be deleted to minimize its on-disk lifetime. + */ +@Composable +fun AttachmentPreviewDialog( + attachmentFile: File, + onDismissRequest: () -> Unit, + onLoaded: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + usePlatformDefaultWidth = false, + dismissOnClickOutside = true, + dismissOnBackPress = true, + ), + ) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + var painter by remember { mutableStateOf(null) } + var showContent by remember { mutableStateOf(false) } + + LaunchedEffect(attachmentFile) { + val loadedPainter = withContext(Dispatchers.IO) { + try { + val bitmap = BitmapFactory.decodeFile(attachmentFile.path) + if (bitmap != null) { + BitmapPainter(bitmap.asImageBitmap()) + } else { + null + } + } finally { + onLoaded() + } + } + + if (loadedPainter != null) { + painter = loadedPainter + showContent = true + } else { + // If the bitmap fails to load, dismiss the dialog. + onDismissRequest() + } + } + + if (showContent && painter != null) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image( + painter = painter!!, + contentDescription = stringResource(BitwardenString.preview), + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + offset = if (scale > 1f) offset + pan else Offset.Zero + } + } + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + ) + + IconButton( + onClick = onDismissRequest, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(16.dp) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.5f)) + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(BitwardenString.close), + tint = Color.White + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt index b237483df4b..bda03565068 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/vault/feature/item/handlers/VaultCommonItemTypeHandlers.kt @@ -17,6 +17,7 @@ data class VaultCommonItemTypeHandlers( Boolean, ) -> Unit, val onAttachmentDownloadClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, + val onAttachmentPreviewClick: (VaultItemState.ViewState.Content.Common.AttachmentItem) -> Unit, val onCopyNotesClick: () -> Unit, val onPasswordHistoryClick: () -> Unit, ) { @@ -50,6 +51,9 @@ data class VaultCommonItemTypeHandlers( onAttachmentDownloadClick = { viewModel.trySendAction(VaultItemAction.Common.AttachmentDownloadClick(it)) }, + onAttachmentPreviewClick = { + viewModel.trySendAction(VaultItemAction.Common.AttachmentPreviewClick(it)) + }, onCopyNotesClick = { viewModel.trySendAction(VaultItemAction.Common.CopyNotesClick) }, 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 5d3e7a8da5c..1c6525b7ee0 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 @@ -87,9 +87,10 @@ fun CipherView.toViewState( ) { null } else { + val fileName = requireNotNull(it.fileName) VaultItemState.ViewState.Content.Common.AttachmentItem( id = requireNotNull(it.id), - title = requireNotNull(it.fileName), + title = fileName, displaySize = requireNotNull(it.sizeName), url = requireNotNull(it.url), isLargeFile = try { @@ -98,6 +99,7 @@ fun CipherView.toViewState( false }, isDownloadAllowed = isPremiumUser || this.organizationId != null, + isPreviewable = isImageFile(fileName), ) } } @@ -289,6 +291,17 @@ private fun CipherView.toIconData( } } +private fun isImageFile(fileName: String?): Boolean { + if (fileName == null) return false + val lowercasedFileName = fileName.lowercase(Locale.getDefault()) + return lowercasedFileName.endsWith(".png") || + lowercasedFileName.endsWith(".jpg") || + lowercasedFileName.endsWith(".jpeg") || + lowercasedFileName.endsWith(".gif") || + lowercasedFileName.endsWith(".webp") || + lowercasedFileName.endsWith(".bmp") +} + @get:DrawableRes private val CipherType.iconRes: Int get() = when (this) { diff --git a/ui/src/main/res/drawable/ic_preview.xml b/ui/src/main/res/drawable/ic_preview.xml new file mode 100644 index 00000000000..292d4ab8ae5 --- /dev/null +++ b/ui/src/main/res/drawable/ic_preview.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index bb3d90b7f09..0afecd281be 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Copy password Copy username Delete + Preview Deleting… Do you really want to delete? This cannot be undone. Edit