Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// swiftlint:disable file_length

import BitwardenResources
import SwiftUI

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)";
Expand Down
20 changes: 20 additions & 0 deletions BitwardenShared/Core/Vault/Extensions/BitwardenSdk+Vault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = ["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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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?)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// MARK: - AttachmentPreviewEffect

/// Effects that can be processed by an `AttachmentPreviewProcessor`.
enum AttachmentPreviewEffect: Equatable {
/// The download button was pressed.
case downloadPressed
}
Original file line number Diff line number Diff line change
@@ -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<VaultItemRoute, VaultItemEvent>

/// 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<VaultItemRoute, VaultItemEvent>,
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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<VaultItemRoute, VaultItemEvent>
let temporaryUrl = URL(fileURLWithPath: "/tmp/photo.png")
let vaultRepository: MockVaultRepository
let subject: AttachmentPreviewProcessor

// MARK: Initialization

init() {
coordinator = MockCoordinator<VaultItemRoute, VaultItemEvent>()
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)
}
}
Loading
Loading