diff --git a/BitwardenKit/UI/Platform/Application/Extensions/View+Toolbar.swift b/BitwardenKit/UI/Platform/Application/Extensions/View+Toolbar.swift index 2bf8bac81a..672e2753a8 100644 --- a/BitwardenKit/UI/Platform/Application/Extensions/View+Toolbar.swift +++ b/BitwardenKit/UI/Platform/Application/Extensions/View+Toolbar.swift @@ -1,3 +1,5 @@ +// swiftlint:disable file_length + import BitwardenResources import SwiftUI @@ -109,6 +111,16 @@ public extension View { .accessibilityIdentifier(accessibilityIdentifier) } + /// Returns a toolbar button configured for downloading an item. + /// + /// - Parameter action: The action to perform when the button is tapped. + /// - Returns: A `Button` configured for downloading an item. + /// + func downloadToolbarButton(action: @escaping () async -> Void) -> some View { + toolbarButton(asset: SharedAsset.Icons.download24, label: Localizations.download, action: action) + .accessibilityIdentifier("DownloadItemButton") + } + /// Returns a toolbar button configured for editing an item. /// /// - Parameter action: The action to perform when the button is tapped. @@ -186,6 +198,26 @@ public extension View { .frame(minHeight: 44) } + /// Returns a `Button` that displays an image for use in a toolbar. + /// + /// - Parameters: + /// - asset: The image asset to show in the button. + /// - label: The label associated with the image, used as an accessibility label. + /// - action: The async action to perform when the button is tapped. + /// - Returns: A `Button` for displaying an image in a toolbar. + /// + func toolbarButton(asset: SharedImageAsset, label: String, action: @escaping () async -> Void) -> some View { + AsyncButton(action: action) { + Image(asset: asset, label: Text(label)) + .imageStyle(.toolbarIcon) + } + .buttonStyle(.toolbar) + // Ideally we would set both `minHeight` and `minWidth` to 44. Setting `minWidth` causes + // padding to be applied equally on both sides of the image. This results in extra padding + // along the margin though. + .frame(minHeight: 44) + } + /// Returns a `Button` that displays a text label for use in a toolbar. /// /// - Parameters: @@ -286,6 +318,17 @@ public extension View { } } + /// A `ToolbarItem` for views with a download button. + /// + /// - Parameter action: The action to perform when the download button is tapped. + /// - Returns: A `ToolbarItem` with a download button. + /// + func downloadToolbarItem(_ action: @escaping () async -> Void) -> some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + downloadToolbarButton(action: action) + } + } + /// A `ToolbarItem` for views with a more button. /// /// - Parameter content: The content to display in the menu when the more icon is tapped. diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index c8c720d332..6e5658d5ed 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -170,6 +170,10 @@ "Attachments" = "Attachments"; "UnableToDownloadFile" = "Unable to download file."; "Downloading" = "Downloading…"; +"OpeningPreview" = "Opening preview…"; +/* %1$@ is the uppercased file extension of the attachment, e.g. "PDF". */ +"PreviewUnavailableForXFilesDescriptionLong" = "Preview unavailable for %1$@ files. You can still download it to view on your device."; +"PreviewUnavailableDescriptionLong" = "Preview unavailable for this file. You can still download it to view on your device."; "AttachmentLargeWarning" = "This attachment is %1$@ in size. Are you sure you want to download it onto your device?"; "AuthenticatorKey" = "Authenticator key"; "VerificationCodeTotp" = "Verification code (TOTP)"; diff --git a/BitwardenShared/Core/Vault/Extensions/BitwardenSdk+Vault.swift b/BitwardenShared/Core/Vault/Extensions/BitwardenSdk+Vault.swift index 966d59d922..dcab094ff0 100644 --- a/BitwardenShared/Core/Vault/Extensions/BitwardenSdk+Vault.swift +++ b/BitwardenShared/Core/Vault/Extensions/BitwardenSdk+Vault.swift @@ -44,6 +44,26 @@ extension AttachmentResponseModel { extension AttachmentView: @retroactive Identifiable {} +extension AttachmentView { + /// File extensions recognized as image formats that can be shown in the in-app attachment preview. + private static let imageExtensions: Set = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"] + + /// The file name's extension, if any (not lowercased). + var fileExtension: String? { + guard let fileName, fileName.contains("."), let ext = fileName.split(separator: ".").last else { + return nil + } + return String(ext) + } + + /// Whether this attachment's file name has an extension recognized as an image format that + /// can be shown in the in-app attachment preview. + var isImage: Bool { + guard let fileExtension else { return false } + return Self.imageExtensions.contains(fileExtension.lowercased()) + } +} + extension CipherBankAccountModel { init(bankAccount: BitwardenSdk.BankAccount) { self.init( diff --git a/BitwardenShared/Core/Vault/Extensions/BitwardenSdkVaultTests.swift b/BitwardenShared/Core/Vault/Extensions/BitwardenSdkVaultTests.swift index b03eb9039c..73ac524d0b 100644 --- a/BitwardenShared/Core/Vault/Extensions/BitwardenSdkVaultTests.swift +++ b/BitwardenShared/Core/Vault/Extensions/BitwardenSdkVaultTests.swift @@ -26,6 +26,39 @@ class BitwardenSdkVaultBitwardenCipherTypeTests: BitwardenTestCase { } } +// MARK: - AttachmentView + +class BitwardenSdkVaultAttachmentViewTests: BitwardenTestCase { + // MARK: Tests + + /// `fileExtension` returns the file name's extension without lowercasing it. + func test_fileExtension() { + XCTAssertEqual(AttachmentView.fixture(fileName: "photo.PNG").fileExtension, "PNG") + XCTAssertEqual(AttachmentView.fixture(fileName: "archive.tar.gz").fileExtension, "gz") + } + + /// `fileExtension` returns `nil` when the file name has no extension or is `nil`. + func test_fileExtension_nil() { + XCTAssertNil(AttachmentView.fixture(fileName: "photo").fileExtension) + XCTAssertNil(AttachmentView.fixture(fileName: nil).fileExtension) + } + + /// `isImage` returns `true` for file names with a recognized image extension, regardless of case. + func test_isImage_true() { + for ext in ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"] { + XCTAssertTrue(AttachmentView.fixture(fileName: "photo.\(ext)").isImage) + XCTAssertTrue(AttachmentView.fixture(fileName: "photo.\(ext.uppercased())").isImage) + } + } + + /// `isImage` returns `false` for non-image extensions, no extension, or a `nil` file name. + func test_isImage_false() { + XCTAssertFalse(AttachmentView.fixture(fileName: "statement.pdf").isImage) + XCTAssertFalse(AttachmentView.fixture(fileName: "photo").isImage) + XCTAssertFalse(AttachmentView.fixture(fileName: nil).isImage) + } +} + // MARK: - Cipher class BitwardenSdkVaultCipherTests: BitwardenTestCase { diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewAction.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewAction.swift new file mode 100644 index 0000000000..35dcf816dc --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewAction.swift @@ -0,0 +1,13 @@ +import BitwardenKit + +// MARK: - AttachmentPreviewAction + +/// Actions that can be processed by an `AttachmentPreviewProcessor`. +/// +enum AttachmentPreviewAction: Equatable, Sendable { + /// The dismiss button was pressed. + case dismissPressed + + /// The toast was shown or hidden. + case toastShown(Toast?) +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewEffect.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewEffect.swift new file mode 100644 index 0000000000..4ba5e865c9 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewEffect.swift @@ -0,0 +1,7 @@ +// MARK: - AttachmentPreviewEffect + +/// Effects that can be processed by an `AttachmentPreviewProcessor`. +enum AttachmentPreviewEffect: Equatable { + /// The download button was pressed. + case downloadPressed +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessor.swift new file mode 100644 index 0000000000..cd9500bb35 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessor.swift @@ -0,0 +1,69 @@ +import BitwardenKit +import Foundation + +// MARK: - AttachmentPreviewProcessor + +/// The processor used to manage state and handle actions for `AttachmentPreviewView`. +/// +class AttachmentPreviewProcessor: StateProcessor< + AttachmentPreviewState, + AttachmentPreviewAction, + AttachmentPreviewEffect, +> { + // MARK: Types + + typealias Services = HasErrorReporter + & HasVaultRepository + + // MARK: Private Properties + + /// The `Coordinator` that handles navigation. + private let coordinator: AnyCoordinator + + /// The services used by this processor. + private let services: Services + + // MARK: Initialization + + /// Creates a new `AttachmentPreviewProcessor`. + /// + /// - Parameters: + /// - coordinator: The `Coordinator` for this processor. + /// - services: The services used by this processor. + /// - state: The initial state of this processor. + /// + init( + coordinator: AnyCoordinator, + services: Services, + state: AttachmentPreviewState, + ) { + self.coordinator = coordinator + self.services = services + + super.init(state: state) + } + + deinit { + // When the preview and anything presented on top of it (e.g. the save file picker) are + // dismissed, ensure any temporary files are deleted. + services.vaultRepository.clearTemporaryDownloads() + } + + // MARK: Methods + + override func perform(_ effect: AttachmentPreviewEffect) async { + switch effect { + case .downloadPressed: + coordinator.navigate(to: .saveFile(temporaryUrl: state.temporaryUrl)) + } + } + + override func receive(_ action: AttachmentPreviewAction) { + switch action { + case .dismissPressed: + coordinator.navigate(to: .dismiss()) + case let .toastShown(newValue): + state.toast = newValue + } + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessorTests.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessorTests.swift new file mode 100644 index 0000000000..722c56d0e0 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewProcessorTests.swift @@ -0,0 +1,85 @@ +import BitwardenKit +import BitwardenKitMocks +import Foundation +import TestHelpers +import Testing + +@testable import BitwardenShared + +// MARK: - AttachmentPreviewProcessorTests + +@MainActor +struct AttachmentPreviewProcessorTests { + // MARK: Properties + + let coordinator: MockCoordinator + let temporaryUrl = URL(fileURLWithPath: "/tmp/photo.png") + let vaultRepository: MockVaultRepository + let subject: AttachmentPreviewProcessor + + // MARK: Initialization + + init() { + coordinator = MockCoordinator() + vaultRepository = MockVaultRepository() + subject = AttachmentPreviewProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: ServiceContainer.withMocks(vaultRepository: vaultRepository), + state: AttachmentPreviewState( + attachment: .fixture(fileName: "photo.png"), + content: .image(Data()), + fileName: "photo.png", + temporaryUrl: temporaryUrl, + ), + ) + } + + // MARK: Tests + + /// `receive(_:)` with `.dismissPressed` navigates to `.dismiss()`. + @Test + func receive_dismissPressed() { + subject.receive(.dismissPressed) + #expect(coordinator.routes.last == .dismiss()) + } + + /// `receive(_:)` with `.toastShown` updates the state's toast. + @Test + func receive_toastShown() { + let toast = Toast(title: "toast") + subject.receive(.toastShown(toast)) + #expect(subject.state.toast == toast) + + subject.receive(.toastShown(nil)) + #expect(subject.state.toast == nil) + } + + /// `perform(_:)` with `.downloadPressed` navigates to `.saveFile(temporaryUrl:)` with the + /// state's temporary url. + @Test + func perform_downloadPressed() async { + await subject.perform(.downloadPressed) + #expect(coordinator.routes.last == .saveFile(temporaryUrl: temporaryUrl)) + } + + /// The processor clears any temporary downloads when it's deallocated. + @Test + func deinit_clearsTemporaryDownloads() { + var localSubject: AttachmentPreviewProcessor? = AttachmentPreviewProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: ServiceContainer.withMocks(vaultRepository: vaultRepository), + state: AttachmentPreviewState( + attachment: .fixture(fileName: "photo.png"), + content: .image(Data()), + fileName: "photo.png", + temporaryUrl: temporaryUrl, + ), + ) + _ = localSubject + + #expect(!vaultRepository.clearTemporaryDownloadsCalled) + localSubject = nil + + #expect(vaultRepository.clearTemporaryDownloadsCalled) + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewState.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewState.swift new file mode 100644 index 0000000000..77a75eb94f --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewState.swift @@ -0,0 +1,101 @@ +import BitwardenKit +@preconcurrency import BitwardenSdk +import Foundation + +// MARK: - AttachmentPreviewContent + +/// The content to display in an `AttachmentPreviewView`, based on the downloaded attachment. +enum AttachmentPreviewContent: Equatable, Hashable, Sendable { + /// The attachment could not be decoded into a displayable image. + case fileError + + /// The attachment is an image that decoded successfully, with its raw data. + case image(Data) + + /// The attachment's file type isn't supported for in-app preview. + case unsupportedFileType(fileExtension: String) +} + +// MARK: - AttachmentPreviewState + +/// An object that defines the current state of an `AttachmentPreviewView`. +/// +struct AttachmentPreviewState: Equatable, Hashable, Sendable { + // MARK: Properties + + /// The attachment being previewed. + let attachment: AttachmentView + + /// The content to display for the attachment. + var content: AttachmentPreviewContent + + /// The attachment's file name. + let fileName: String + + /// The url where the decrypted attachment is temporarily stored. + let temporaryUrl: URL + + /// A toast message to show in the view. + var toast: Toast? + + // MARK: Computed Properties + + /// The file name, middle-truncated to fit the navigation bar title if necessary, always + /// preserving the trailing file extension. + var truncatedFileName: String { + Self.truncateMiddle(fileName, maxLength: Self.maxTruncatedFileNameLength) + } + + // MARK: Hashable, Equatable + + /// `toast` is transient view-only state and isn't part of this value's identity, so it's + /// excluded here. This lets `AttachmentPreviewState` remain `Hashable`, which `VaultItemRoute` + /// requires, even though `Toast` itself isn't `Hashable`. + static func == (lhs: AttachmentPreviewState, rhs: AttachmentPreviewState) -> Bool { + lhs.attachment == rhs.attachment + && lhs.content == rhs.content + && lhs.fileName == rhs.fileName + && lhs.temporaryUrl == rhs.temporaryUrl + } + + func hash(into hasher: inout Hasher) { + hasher.combine(attachment) + hasher.combine(content) + hasher.combine(fileName) + hasher.combine(temporaryUrl) + } +} + +// MARK: - Private + +private extension AttachmentPreviewState { + /// The maximum length, in characters, of the truncated file name shown in the navigation bar. + static let maxTruncatedFileNameLength = 40 + + /// Middle-truncates a string to `maxLength` characters, preserving the trailing file + /// extension (if any). + /// + /// - Parameters: + /// - string: The string to truncate. + /// - maxLength: The maximum length of the returned string. + /// - Returns: The truncated string, or the original string if it's already short enough. + static func truncateMiddle(_ string: String, maxLength: Int) -> String { + guard string.count > maxLength else { return string } + + let ellipsis = "…" + let pathExtension = (string as NSString).pathExtension + let extensionSuffix = pathExtension.isEmpty ? "" : ".\(pathExtension)" + let name = extensionSuffix.isEmpty ? string : String(string.dropLast(extensionSuffix.count)) + + let availableLength = maxLength - ellipsis.count - extensionSuffix.count + guard availableLength > 0, name.count > availableLength else { + return String(string.prefix(max(maxLength - ellipsis.count, 0))) + ellipsis + } + + let headLength = (availableLength + 1) / 2 + let tailLength = availableLength / 2 + let head = name.prefix(headLength) + let tail = name.suffix(tailLength) + return "\(head)\(ellipsis)\(tail)\(extensionSuffix)" + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewStateTests.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewStateTests.swift new file mode 100644 index 0000000000..b32f0f91c1 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewStateTests.swift @@ -0,0 +1,55 @@ +import BitwardenSdk +import Foundation +import Testing + +@testable import BitwardenShared + +// MARK: - AttachmentPreviewStateTests + +struct AttachmentPreviewStateTests { + // MARK: Tests + + /// `truncatedFileName` leaves short file names unaffected. + @Test + func truncatedFileName_shortName() { + let subject = AttachmentPreviewState( + attachment: .fixture(fileName: "photo.png"), + content: .fileError, + fileName: "photo.png", + temporaryUrl: URL(fileURLWithPath: "/tmp/photo.png"), + ) + #expect(subject.truncatedFileName == "photo.png") + } + + /// `truncatedFileName` middle-truncates a long file name while preserving the extension. + @Test + func truncatedFileName_longName() { + let name = String(repeating: "a", count: 50) + ".pdf" + let subject = AttachmentPreviewState( + attachment: .fixture(fileName: name), + content: .fileError, + fileName: name, + temporaryUrl: URL(fileURLWithPath: "/tmp/\(name)"), + ) + + let expected = String(repeating: "a", count: 18) + "…" + String(repeating: "a", count: 17) + ".pdf" + #expect(subject.truncatedFileName == expected) + #expect(subject.truncatedFileName.count == 40) + } + + /// `truncatedFileName` middle-truncates a long file name with no extension. + @Test + func truncatedFileName_longName_noExtension() { + let name = String(repeating: "b", count: 50) + let subject = AttachmentPreviewState( + attachment: .fixture(fileName: name), + content: .fileError, + fileName: name, + temporaryUrl: URL(fileURLWithPath: "/tmp/\(name)"), + ) + + let expected = String(repeating: "b", count: 20) + "…" + String(repeating: "b", count: 19) + #expect(subject.truncatedFileName == expected) + #expect(subject.truncatedFileName.count == 40) + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView+SnapshotTests.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView+SnapshotTests.swift new file mode 100644 index 0000000000..c2fdf12195 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView+SnapshotTests.swift @@ -0,0 +1,106 @@ +// swiftlint:disable:this file_name +import BitwardenKit +import BitwardenKitMocks +import BitwardenSdk +import Foundation +import SnapshotTesting +import UIKit +import XCTest + +@testable import BitwardenShared + +class AttachmentPreviewViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: AttachmentPreviewView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + processor = MockProcessor(state: .fixture()) + let store = Store(processor: processor) + + subject = AttachmentPreviewView(store: store) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + } + + // MARK: Previews + + /// The image content renders correctly. + @MainActor + func disabletest_snapshot_attachmentPreview_image() { + processor.state = .fixture(content: .image(UIImage(systemName: "photo")?.pngData() ?? Data())) + assertSnapshots( + of: subject.navStackWrapped, + as: [ + .defaultPortrait, + .defaultPortraitDark, + ], + ) + } + + /// The unsupported file type content renders correctly. + @MainActor + func disabletest_snapshot_attachmentPreview_unsupportedFileType() { + processor.state = .fixture(content: .unsupportedFileType(fileExtension: "PDF")) + assertSnapshots( + of: subject.navStackWrapped, + as: [ + .defaultPortrait, + .defaultPortraitDark, + .defaultPortraitAX5, + ], + ) + } + + /// The file error content renders correctly. + @MainActor + func disabletest_snapshot_attachmentPreview_fileError() { + processor.state = .fixture(content: .fileError) + assertSnapshots( + of: subject.navStackWrapped, + as: [ + .defaultPortrait, + .defaultPortraitDark, + ], + ) + } + + /// A long file name is truncated in the navigation bar. + @MainActor + func disabletest_snapshot_attachmentPreview_longFileName() { + let fileName = String(repeating: "selfieWithACat", count: 5) + ".png" + processor.state = .fixture(fileName: fileName) + assertSnapshots( + of: subject.navStackWrapped, + as: [.defaultPortrait], + ) + } +} + +// MARK: - AttachmentPreviewState Fixture + +private extension AttachmentPreviewState { + static func fixture( + attachment: AttachmentView = .fixture(fileName: "photo.png"), + content: AttachmentPreviewContent = .fileError, + fileName: String = "photo.png", + temporaryUrl: URL = URL(fileURLWithPath: "/tmp/photo.png"), + ) -> AttachmentPreviewState { + AttachmentPreviewState( + attachment: attachment, + content: content, + fileName: fileName, + temporaryUrl: temporaryUrl, + ) + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView.swift new file mode 100644 index 0000000000..aea15f4c86 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/AttachmentPreviewView.swift @@ -0,0 +1,124 @@ +import BitwardenKit +import BitwardenResources +import SwiftUI +#if DEBUG +import UIKit +#endif + +// MARK: - AttachmentPreviewView + +/// A view that previews a vault item's attachment. +/// +struct AttachmentPreviewView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + // MARK: View + + var body: some View { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(SharedAsset.Colors.backgroundPrimary.swiftUIColor) + .navigationBar(title: "", titleDisplayMode: .inline) + .toolbar { + closeToolbarItem { + store.send(.dismissPressed) + } + + ToolbarItem(placement: .principal) { + Text(store.state.truncatedFileName) + .styleGuide(.headline) + .lineLimit(1) + } + + downloadToolbarItem { + await store.perform(.downloadPressed) + } + } + .toast(store.binding( + get: \.toast, + send: AttachmentPreviewAction.toastShown, + )) + } + + // MARK: Private Views + + /// The main content of the view, based on the current preview content state. + @ViewBuilder private var content: some View { + switch store.state.content { + case let .image(data): + ZoomableImageView(data: data) + case let .unsupportedFileType(fileExtension): + emptyStateView( + message: Localizations.previewUnavailableForXFilesDescriptionLong(fileExtension.uppercased()), + ) + case .fileError: + emptyStateView(message: Localizations.previewUnavailableDescriptionLong) + } + } + + /// The empty state shown for unsupported file types or files that failed to decode, with a + /// button to fall back to downloading the file. + /// + /// - Parameter message: The message explaining why the file can't be previewed. + /// + private func emptyStateView(message: String) -> some View { + // TODO: PM-33413 swap in the final Figma illustration once design provides it. + IllustratedMessageView( + image: Asset.Images.Illustrations.dataBreach.swiftUIImage, + style: .mediumImage, + message: message, + ) { + Button(Localizations.download) { + Task { await store.perform(.downloadPressed) } + } + .buttonStyle(.primary(shouldFillWidth: false)) + } + .padding(.horizontal, 16) + } +} + +// MARK: - Previews + +#if DEBUG +#Preview("Image") { + NavigationView { + AttachmentPreviewView(store: Store(processor: StateProcessor( + state: AttachmentPreviewState( + attachment: .fixture(fileName: "selfieWithACat.png"), + content: .image(UIImage(systemName: "photo")?.pngData() ?? Data()), + fileName: "selfieWithACat.png", + temporaryUrl: URL(fileURLWithPath: "/tmp/preview"), + ), + ))) + } +} + +#Preview("Unsupported File Type") { + NavigationView { + AttachmentPreviewView(store: Store(processor: StateProcessor( + state: AttachmentPreviewState( + attachment: .fixture(fileName: "statement.pdf"), + content: .unsupportedFileType(fileExtension: "pdf"), + fileName: "statement.pdf", + temporaryUrl: URL(fileURLWithPath: "/tmp/preview"), + ), + ))) + } +} + +#Preview("File Error") { + NavigationView { + AttachmentPreviewView(store: Store(processor: StateProcessor( + state: AttachmentPreviewState( + attachment: .fixture(fileName: "selfieWithADog.png"), + content: .fileError, + fileName: "selfieWithADog.png", + temporaryUrl: URL(fileURLWithPath: "/tmp/preview"), + ), + ))) + } +} +#endif diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/ZoomableImageView.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/ZoomableImageView.swift new file mode 100644 index 0000000000..36fd49cb48 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreview/ZoomableImageView.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftUI +import UIKit + +// MARK: - ZoomableImageView + +/// A view that displays image data with pinch-to-zoom and, once zoomed in, drag-to-pan gestures. +/// +struct ZoomableImageView: View { + // MARK: Properties + + /// The image data to display. + let data: Data + + // MARK: Private Properties + + /// The current pan offset, combining the committed offset and any in-progress drag gesture. + @SwiftUI.State private var offset: CGSize = .zero + + /// The offset committed at the end of the last drag gesture. + @SwiftUI.State private var committedOffset: CGSize = .zero + + /// The current zoom scale, combining the committed scale and any in-progress magnification gesture. + @SwiftUI.State private var scale: CGFloat = Self.minScale + + /// The scale committed at the end of the last magnification gesture. + @SwiftUI.State private var committedScale: CGFloat = Self.minScale + + // MARK: View + + var body: some View { + if let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .scaleEffect(scale) + .offset(offset) + .simultaneousGesture(magnificationGesture) + .simultaneousGesture(dragGesture) + .accessibilityIdentifier("AttachmentPreviewImage") + } + } + + /// A gesture that pans the image once it's zoomed in. + private var dragGesture: some Gesture { + DragGesture() + .onChanged { value in + guard committedScale > Self.minScale else { return } + offset = CGSize( + width: committedOffset.width + value.translation.width, + height: committedOffset.height + value.translation.height, + ) + } + .onEnded { _ in + committedOffset = offset + } + } + + /// A gesture that zooms the image between `minScale` and `maxScale`. + private var magnificationGesture: some Gesture { + MagnificationGesture() + .onChanged { value in + scale = min(max(committedScale * value, Self.minScale), Self.maxScale) + } + .onEnded { _ in + committedScale = scale + guard committedScale == Self.minScale else { return } + withAnimation { + offset = .zero + committedOffset = .zero + } + } + } + + // MARK: Initialization + + /// Creates a new `ZoomableImageView`. + /// + /// - Parameter data: The image data to display. + /// + init(data: Data) { + self.data = data + } +} + +// MARK: - Constants + +private extension ZoomableImageView { + /// The maximum allowed zoom scale. + static let maxScale: CGFloat = 5 + + /// The minimum allowed zoom scale. + static let minScale: CGFloat = 1 +} + +// MARK: - Previews + +#if DEBUG +#Preview { + ZoomableImageView(data: UIImage(systemName: "photo")?.pngData() ?? Data()) +} +#endif diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift new file mode 100644 index 0000000000..64a3de9050 --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift @@ -0,0 +1,116 @@ +import BitwardenKit +import BitwardenResources +import BitwardenSdk +import Foundation +import UIKit + +/// A protocol for a helper to centralize downloading and previewing a vault item's attachment. +protocol AttachmentPreviewHelper { // sourcery: AutoMockable + /// Downloads an attachment and shows the in-app preview screen for it. + /// + /// - Parameters: + /// - attachment: The attachment to preview. + /// - cipher: The cipher that owns the attachment. + func showPreview( + for attachment: AttachmentView, + cipher: CipherView, + ) async +} + +/// The default implementation of `AttachmentPreviewHelper`. +@MainActor +class DefaultAttachmentPreviewHelper: AttachmentPreviewHelper { + // MARK: Types + + typealias Services = HasErrorReporter + & HasVaultRepository + + // MARK: Private Properties + + /// The `Coordinator` that handles navigation. + private var coordinator: AnyCoordinator + + /// The services used by this helper. + private var services: Services + + // MARK: Initialization + + /// Initialize a `DefaultAttachmentPreviewHelper`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - services: The services used by this helper. + /// + init( + coordinator: AnyCoordinator, + services: Services, + ) { + self.coordinator = coordinator + self.services = services + } + + // MARK: Methods + + func showPreview( + for attachment: AttachmentView, + cipher: CipherView, + ) async { + if let sizeName = attachment.sizeName, + let size = Int(attachment.size ?? ""), + size >= Constants.largeFileSize { + coordinator.showAlert(.confirmDownload(fileSize: sizeName) { + await self.downloadAndPreview(attachment, cipher: cipher) + }) + } else { + await downloadAndPreview(attachment, cipher: cipher) + } + } + + // MARK: Private Methods + + /// Classifies the downloaded file's content for display in the preview screen. + /// + /// - Parameter attachment: The attachment that was downloaded. + /// - Parameter temporaryUrl: The url where the downloaded file is stored. + /// - Returns: The content to show in the preview screen. + private func classify(_ attachment: AttachmentView, temporaryUrl: URL) -> AttachmentPreviewContent { + guard attachment.isImage else { + return .unsupportedFileType(fileExtension: attachment.fileExtension ?? "") + } + guard let data = try? Data(contentsOf: temporaryUrl), UIImage(data: data) != nil else { + return .fileError + } + return .image(data) + } + + /// Downloads the attachment and navigates to the preview screen, classifying the downloaded + /// content along the way. + /// + /// - Parameters: + /// - attachment: The attachment to download. + /// - cipher: The cipher that owns the attachment. + private func downloadAndPreview(_ attachment: AttachmentView, cipher: CipherView) async { + defer { coordinator.hideLoadingOverlay() } + do { + coordinator.showLoadingOverlay(title: Localizations.openingPreview) + + guard let temporaryUrl = try await services.vaultRepository.downloadAttachment( + attachment, + cipher: cipher, + ) else { + return coordinator.showAlert(.defaultAlert(title: Localizations.unableToDownloadFile)) + } + + coordinator.hideLoadingOverlay() + coordinator.navigate(to: .attachmentPreview(AttachmentPreviewState( + attachment: attachment, + content: classify(attachment, temporaryUrl: temporaryUrl), + fileName: attachment.fileName ?? "", + temporaryUrl: temporaryUrl, + ))) + } catch { + coordinator.showAlert(.defaultAlert(title: Localizations.unableToDownloadFile)) + services.errorReporter.log(error: error) + } + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift new file mode 100644 index 0000000000..b16f38776f --- /dev/null +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift @@ -0,0 +1,197 @@ +import BitwardenKit +import BitwardenKitMocks +import BitwardenResources +import BitwardenSdk +import Foundation +import TestHelpers +import Testing +import UIKit + +@testable import BitwardenShared + +// MARK: - AttachmentPreviewHelperTests + +@MainActor +struct AttachmentPreviewHelperTests { + // MARK: Properties + + let coordinator: MockCoordinator + let errorReporter: MockErrorReporter + let vaultRepository: MockVaultRepository + let subject: AttachmentPreviewHelper + + // MARK: Initialization + + init() { + coordinator = MockCoordinator() + errorReporter = MockErrorReporter() + vaultRepository = MockVaultRepository() + subject = DefaultAttachmentPreviewHelper( + coordinator: coordinator.asAnyCoordinator(), + services: ServiceContainer.withMocks( + errorReporter: errorReporter, + vaultRepository: vaultRepository, + ), + ) + } + + // MARK: Tests + + /// `showPreview(for:cipher:)` shows a confirmation alert before downloading a large attachment + /// and only downloads once the user confirms. + @Test + func showPreview_largeFile_confirmation() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "11000000", sizeName: "big") + let cipher = CipherView.loginFixture() + + await subject.showPreview(for: attachment, cipher: cipher) + + let alert = try #require(coordinator.alertShown.last) + #expect(alert.title == Localizations.attachmentLargeWarning("big")) + #expect(vaultRepository.downloadAttachmentAttachment == nil) + + vaultRepository.downloadAttachmentResult = try .success(writeTemporaryImage()) + try await alert.tapAction(title: Localizations.yes) + + #expect(vaultRepository.downloadAttachmentAttachment == attachment) + guard case .attachmentPreview = coordinator.routes.last else { + Issue.record("Expected a navigation to .attachmentPreview") + return + } + } + + /// `showPreview(for:cipher:)` doesn't download a large attachment if the user cancels the + /// confirmation alert. + @Test + func showPreview_largeFile_cancel() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "11000000", sizeName: "big") + let cipher = CipherView.loginFixture() + + await subject.showPreview(for: attachment, cipher: cipher) + + let alert = try #require(coordinator.alertShown.last) + try await alert.tapAction(title: Localizations.no) + + #expect(vaultRepository.downloadAttachmentAttachment == nil) + #expect(coordinator.routes.isEmpty) + } + + /// `showPreview(for:cipher:)` skips the confirmation alert for small attachments. + @Test + func showPreview_smallFile() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + vaultRepository.downloadAttachmentResult = try .success(writeTemporaryImage()) + + await subject.showPreview(for: attachment, cipher: cipher) + + #expect(coordinator.alertShown.isEmpty) + #expect(vaultRepository.downloadAttachmentAttachment == attachment) + } + + /// `showPreview(for:cipher:)` navigates to the preview screen with `.image(data)` content when + /// the attachment is an image that decodes successfully. + @Test + func showPreview_image_success() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + let temporaryUrl = try writeTemporaryImage() + vaultRepository.downloadAttachmentResult = .success(temporaryUrl) + + await subject.showPreview(for: attachment, cipher: cipher) + + guard case let .attachmentPreview(state) = coordinator.routes.last else { + Issue.record("Expected a navigation to .attachmentPreview") + return + } + guard case let .image(data) = state.content else { + Issue.record("Expected .image content") + return + } + #expect(!data.isEmpty) + #expect(state.attachment == attachment) + #expect(state.temporaryUrl == temporaryUrl) + #expect(!coordinator.isLoadingOverlayShowing) + #expect(coordinator.loadingOverlaysShown.last?.title == Localizations.openingPreview) + } + + /// `showPreview(for:cipher:)` navigates to the preview screen with `.fileError` content when + /// the downloaded image data can't be decoded. + @Test + func showPreview_image_corruptData() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + let temporaryUrl = try writeTemporaryFile(data: Data("not an image".utf8)) + vaultRepository.downloadAttachmentResult = .success(temporaryUrl) + + await subject.showPreview(for: attachment, cipher: cipher) + + guard case let .attachmentPreview(state) = coordinator.routes.last else { + Issue.record("Expected a navigation to .attachmentPreview") + return + } + #expect(state.content == .fileError) + } + + /// `showPreview(for:cipher:)` navigates to the preview screen with `.unsupportedFileType` + /// content for a non-image attachment, without attempting to read/decode its data. + @Test + func showPreview_nonImage() async throws { + let attachment = AttachmentView.fixture(fileName: "statement.pdf", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + let temporaryUrl = try writeTemporaryFile(data: Data("%PDF-1.4".utf8)) + vaultRepository.downloadAttachmentResult = .success(temporaryUrl) + + await subject.showPreview(for: attachment, cipher: cipher) + + guard case let .attachmentPreview(state) = coordinator.routes.last else { + Issue.record("Expected a navigation to .attachmentPreview") + return + } + #expect(state.content == .unsupportedFileType(fileExtension: "pdf")) + } + + /// `showPreview(for:cipher:)` shows the existing blocking alert and doesn't navigate when the + /// download returns no url. + @Test + func showPreview_download_nilUrl() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + vaultRepository.downloadAttachmentResult = .success(nil) + + await subject.showPreview(for: attachment, cipher: cipher) + + #expect(coordinator.alertShown.last == .defaultAlert(title: Localizations.unableToDownloadFile)) + #expect(coordinator.routes.isEmpty) + } + + /// `showPreview(for:cipher:)` shows the existing blocking alert and doesn't navigate when the + /// download throws. + @Test + func showPreview_download_error() async throws { + let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small") + let cipher = CipherView.loginFixture() + vaultRepository.downloadAttachmentResult = .failure(BitwardenTestError.example) + + await subject.showPreview(for: attachment, cipher: cipher) + + #expect(coordinator.alertShown.last == .defaultAlert(title: Localizations.unableToDownloadFile)) + #expect(coordinator.routes.isEmpty) + #expect(errorReporter.errors as? [BitwardenTestError] == [.example]) + } + + // MARK: Private Methods + + /// Writes valid image data to a temporary file and returns its url. + private func writeTemporaryImage() throws -> URL { + let data = try #require(UIImage(systemName: "photo")?.pngData()) + return try writeTemporaryFile(data: data) + } + + /// Writes the given data to a temporary file and returns its url. + private func writeTemporaryFile(data: Data) throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try data.write(to: url) + return url + } +} diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift index 7eeaf3e26b..257d23cc6b 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinator.swift @@ -59,6 +59,12 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl /// The stack navigator that is managed by this coordinator. private(set) weak var stackNavigator: StackNavigator? + /// The helper used to download and preview an attachment. + private lazy var attachmentPreviewHelper = DefaultAttachmentPreviewHelper( + coordinator: asAnyCoordinator(), + services: services, + ) + /// The helper to use to execute vault item actions centralized. private lazy var vaultItemActionHelper = DefaultVaultItemActionHelper( coordinator: asAnyCoordinator(), @@ -116,6 +122,8 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl ) case let .attachments(cipher): showAttachments(for: cipher) + case let .attachmentPreview(state): + showAttachmentPreview(state: state) case let .cloneItem(cipher, hasPremium): showCloneItem(for: cipher, delegate: context as? CipherItemOperationDelegate, hasPremium: hasPremium) case let .dismiss(onDismiss): @@ -231,6 +239,19 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl stackNavigator?.replace(view) } + /// Shows the attachment preview screen. + /// + /// - Parameter state: The initial state of the attachment preview screen. + /// + private func showAttachmentPreview(state: AttachmentPreviewState) { + let processor = AttachmentPreviewProcessor( + coordinator: asAnyCoordinator(), + services: services, + state: state, + ) + stackNavigator?.present(AttachmentPreviewView(store: Store(processor: processor)), overFullscreen: true) + } + /// Shows the attachments screen. /// /// - Parameter cipher: The cipher to show the attachments for. @@ -479,6 +500,7 @@ class VaultItemCoordinator: NSObject, Coordinator, HasStackNavigator { // swiftl /// private func showViewItem(id: String, delegate: CipherItemOperationDelegate?) { let processor = ViewItemProcessor( + attachmentPreviewHelper: attachmentPreviewHelper, coordinator: asAnyCoordinator(), delegate: delegate, itemId: id, diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift index 3691ee813c..c1db204898 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemCoordinatorTests.swift @@ -253,6 +253,25 @@ class VaultItemCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this t XCTAssertEqual(action.embedInNavigationController, true) } + /// `navigate(to:)` with `.attachmentPreview()` presents the attachment preview screen over + /// full screen. + @MainActor + func test_navigateTo_attachmentPreview() throws { + let state = AttachmentPreviewState( + attachment: .fixture(fileName: "photo.png"), + content: .fileError, + fileName: "photo.png", + temporaryUrl: URL(fileURLWithPath: "/tmp/photo.png"), + ) + subject.navigate(to: .attachmentPreview(state)) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertTrue(action.view is AttachmentPreviewView) + XCTAssertEqual(action.embedInNavigationController, true) + XCTAssertEqual(action.overFullscreen, true) + } + /// `navigate(to:)` with `.generator`, `.password`, and a delegate presents the generator /// screen. @MainActor diff --git a/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift b/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift index 05b9fca649..144ac9ba06 100644 --- a/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift +++ b/BitwardenShared/UI/Vault/VaultItem/VaultItemRoute.swift @@ -31,6 +31,12 @@ enum VaultItemRoute: Equatable, Hashable { /// case attachments(_ cipher: CipherView) + /// A route to preview an attachment. + /// + /// - Parameter state: The initial state of the attachment preview screen. + /// + case attachmentPreview(_ state: AttachmentPreviewState) + /// A route to the clone item screen. /// /// - Parameters: diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift index eb55f0cb6d..8b78946ca3 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift @@ -57,6 +57,9 @@ final class ViewItemProcessor: StateProcessor @@ -90,6 +93,7 @@ final class ViewItemProcessor: StateProcessor, delegate: CipherItemOperationDelegate?, itemId: String, @@ -105,6 +110,7 @@ final class ViewItemProcessor: StateProcessor= Constants.largeFileSize { - coordinator.showAlert(.confirmDownload(fileSize: sizeName) { - await self.downloadAttachment(attachment) - }) - } else { - Task { await downloadAttachment(attachment) } - } - } - /// Copies a value to the pasteboard and shows a toast for the field that was copied. /// /// - Parameters: @@ -331,31 +323,6 @@ private extension ViewItemProcessor { } } - /// Download the attachment. - /// - /// - Parameter attachment: The attachment to download. - /// - private func downloadAttachment(_ attachment: AttachmentView) async { - defer { coordinator.hideLoadingOverlay() } - do { - guard case let .data(cipherState) = state.loadingState else { return } - coordinator.showLoadingOverlay(LoadingOverlayState(title: Localizations.downloading)) - - guard let temporaryUrl = try await services.vaultRepository.downloadAttachment( - attachment, - cipher: cipherState.cipher, - ) else { - return coordinator.showAlert(.defaultAlert(title: Localizations.unableToDownloadFile)) - } - - coordinator.hideLoadingOverlay() - coordinator.navigate(to: .saveFile(temporaryUrl: temporaryUrl)) - } catch { - coordinator.showAlert(.defaultAlert(title: Localizations.unableToDownloadFile)) - services.errorReporter.log(error: error) - } - } - /// Dismisses with an action. /// - Parameter action: Action to execute when dismissing this view. private func dismiss(action: @escaping () -> Void) { diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift index 4a0ad4a2d1..769e65a03e 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift @@ -13,6 +13,7 @@ import XCTest class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body_length // MARK: Properties + var attachmentPreviewHelper: MockAttachmentPreviewHelper! var authRepository: MockAuthRepository! var billingRepository: MockBillingRepository! var billingService: MockBillingService! @@ -34,6 +35,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type override func setUp() { super.setUp() + attachmentPreviewHelper = MockAttachmentPreviewHelper() authRepository = MockAuthRepository() billingRepository = MockBillingRepository() billingService = MockBillingService() @@ -63,6 +65,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type vaultRepository: vaultRepository, ) subject = ViewItemProcessor( + attachmentPreviewHelper: attachmentPreviewHelper, coordinator: coordinator.asAnyCoordinator(), delegate: delegate, itemId: "id", @@ -75,6 +78,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type override func tearDown() { super.tearDown() + attachmentPreviewHelper = nil authRepository = nil billingRepository = nil billingService = nil @@ -132,6 +136,7 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type vaultRepository: vaultRepository, ) subject = ViewItemProcessor( + attachmentPreviewHelper: attachmentPreviewHelper, coordinator: coordinator.asAnyCoordinator(), delegate: delegate, itemId: "id", @@ -1434,99 +1439,20 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type XCTAssertNil(subject.state.url) } - /// `.receive(_:)` with `.downloadAttachment(_)` shows an alert and downloads the attachment for large attachments. + /// `.receive(_:)` with `.downloadAttachment(_)` delegates to the attachment preview helper + /// with the attachment and cipher. @MainActor - func test_receive_downloadAttachment() async throws { - // Set up the mock results. - vaultRepository.downloadAttachmentResult = .success(.example) + func test_receive_downloadAttachment() throws { let attachment = AttachmentView.fixture(size: "11000000", sizeName: "big") let cipher = CipherView.fixture(attachments: [attachment]) let state = try XCTUnwrap(CipherItemState(existing: cipher, hasPremium: true)) subject.state.loadingState = .data(state) - // Attempt to download the attachment. subject.receive(.downloadAttachment(attachment)) - // Confirm on the alert - let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) - await confirmAction.handler?(confirmAction, []) - - // Confirm the results. - XCTAssertEqual(vaultRepository.downloadAttachmentAttachment, attachment) - XCTAssertFalse(coordinator.isLoadingOverlayShowing) - XCTAssertEqual(coordinator.loadingOverlaysShown.last?.title, Localizations.downloading) - XCTAssertEqual(coordinator.routes.last, .saveFile(temporaryUrl: .example)) - } - - /// `.receive(_:)` with `.downloadAttachment(_)`handles any errors. - @MainActor - func test_receive_downloadAttachment_error() async throws { - // Set up the mock results. - vaultRepository.downloadAttachmentResult = .failure(BitwardenTestError.example) - let attachment = AttachmentView.fixture(size: "11000000", sizeName: "big") - let cipher = CipherView.fixture(attachments: [attachment]) - let state = try XCTUnwrap(CipherItemState(existing: cipher, hasPremium: true)) - subject.state.loadingState = .data(state) - - // Attempt to download the attachment. - subject.receive(.downloadAttachment(attachment)) - - // Confirm on the alert - let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) - await confirmAction.handler?(confirmAction, []) - - // Confirm the results. - XCTAssertEqual(vaultRepository.downloadAttachmentAttachment, attachment) - XCTAssertFalse(coordinator.isLoadingOverlayShowing) - XCTAssertEqual(coordinator.loadingOverlaysShown.last?.title, Localizations.downloading) - XCTAssertEqual(coordinator.alertShown.last, .defaultAlert(title: Localizations.unableToDownloadFile)) - XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example) - } - - /// `.receive(_:)` with `.downloadAttachment(_)` shows an alert if the data wasn't saved to a url. - @MainActor - func test_receive_downloadAttachment_nilUrl() async throws { - // Set up the mock results. - vaultRepository.downloadAttachmentResult = .success(nil) - let attachment = AttachmentView.fixture(size: "11000000", sizeName: "big") - let cipher = CipherView.fixture(attachments: [attachment]) - let state = try XCTUnwrap(CipherItemState(existing: cipher, hasPremium: true)) - subject.state.loadingState = .data(state) - - // Attempt to download the attachment. - subject.receive(.downloadAttachment(attachment)) - - // Confirm on the alert - let confirmAction = try XCTUnwrap(coordinator.alertShown.last?.alertActions.first) - await confirmAction.handler?(confirmAction, []) - - // Confirm the results. - XCTAssertEqual(vaultRepository.downloadAttachmentAttachment, attachment) - XCTAssertFalse(coordinator.isLoadingOverlayShowing) - XCTAssertEqual(coordinator.loadingOverlaysShown.last?.title, Localizations.downloading) - XCTAssertEqual(coordinator.alertShown.last, .defaultAlert(title: Localizations.unableToDownloadFile)) - } - - /// `.receive(_:)` with `.downloadAttachment(_)` skips the confirmation alert for small files.. - @MainActor - func test_receive_downloadAttachment_smallAttachment() throws { - // Set up the mock results. - vaultRepository.downloadAttachmentResult = .success(.example) - let attachment = AttachmentView.fixture(size: "10", sizeName: "small") - let cipher = CipherView.fixture(attachments: [attachment]) - let state = try XCTUnwrap(CipherItemState(existing: cipher, hasPremium: true)) - subject.state.loadingState = .data(state) - - // Attempt to download the attachment. - let task = Task { - subject.receive(.downloadAttachment(attachment)) - } - - // Confirm the results. - waitFor(!coordinator.routes.isEmpty) - task.cancel() - XCTAssertTrue(coordinator.alertShown.isEmpty) - XCTAssertEqual(coordinator.routes.last, .saveFile(temporaryUrl: .example)) + waitFor(attachmentPreviewHelper.showPreviewCalled) + XCTAssertEqual(attachmentPreviewHelper.showPreviewReceivedArguments?.attachment, attachment) + XCTAssertEqual(attachmentPreviewHelper.showPreviewReceivedArguments?.cipher, cipher) } /// `receive` with `.editPressed` has no change when the state is loading.