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
Expand Up @@ -1069,6 +1069,7 @@
"OnceArchivedThisItemWillBeExcludedDescriptionLong" = "Once archived, this item will be excluded from search results and autofill suggestions.";
"ArchivingItemsIsAPremiumFeatureDescriptionLong" = "Archiving items is a Premium feature. Your current plan does not include access to this feature.";
"AddingAttachmentsIsAPremiumFeatureDescriptionLong" = "Adding attachments is a Premium feature. Your current plan does not include access to this feature.";
"ViewingAndDownloadingAttachmentsIsAPremiumFeatureDescriptionLong" = "Viewing and downloading attachments is a Premium feature. Your current plan does not include access to this feature.";
"UpgradeToPremium" = "Upgrade to Premium";
"ThisItemIsArchived" = "This item is archived.";
"YourPremiumSubscriptionEnded" = "Your Premium subscription ended";
Expand Down
23 changes: 23 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,29 @@ extension Alert {
return alert
}

/// Returns an alert for when previewing or downloading an existing attachment is unavailable.
///
/// - Parameters:
/// - action: A closure to execute on upgrading to Premium.
/// - Returns: The alert when attachment preview/download is unavailable.
static func attachmentPreviewUnavailable(
action: @escaping () async -> Void,
) -> Alert {
let preferredAction = AlertAction(title: Localizations.upgradeToPremium, style: .default) { _ in
await action()
}
let alert = Alert(
title: Localizations.premiumSubscriptionRequired,
message: Localizations.viewingAndDownloadingAttachmentsIsAPremiumFeatureDescriptionLong,
alertActions: [
preferredAction,
AlertAction(title: Localizations.cancel, style: .cancel),
],
)
alert.preferredAction = preferredAction
return alert
}

/// Returns an alert notifying the user that one or more items in their vault were unable to be
/// decrypted.
///
Expand Down
33 changes: 33 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,39 @@ class AlertVaultTests: BitwardenTestCase { // swiftlint:disable:this type_body_l
XCTAssertFalse(called)
}

/// `attachmentPreviewUnavailable(action:)` returns an `Alert` notifying the user that
/// previewing or downloading an attachment is unavailable and requires Premium.
func test_attachmentPreviewUnavailable() async throws {
var called = false
let subject = Alert.attachmentPreviewUnavailable { called = true }

XCTAssertEqual(subject.title, Localizations.premiumSubscriptionRequired)
XCTAssertEqual(
subject.message,
Localizations.viewingAndDownloadingAttachmentsIsAPremiumFeatureDescriptionLong,
)
XCTAssertEqual(subject.alertActions.count, 2)
XCTAssertEqual(subject.alertActions[0].title, Localizations.upgradeToPremium)
XCTAssertEqual(subject.alertActions[0].style, .default)
XCTAssertEqual(subject.alertActions[1].title, Localizations.cancel)
XCTAssertEqual(subject.alertActions[1].style, .cancel)

let preferredAction = try XCTUnwrap(subject.preferredAction)
XCTAssertTrue(preferredAction === subject.alertActions[0])

try await subject.tapAction(title: Localizations.upgradeToPremium)
XCTAssertTrue(called)
}

/// `attachmentPreviewUnavailable(action:)` doesn't call the action when cancel is tapped.
func test_attachmentPreviewUnavailable_cancel() async throws {
var called = false
let subject = Alert.attachmentPreviewUnavailable { called = true }

try await subject.tapCancel()
XCTAssertFalse(called)
}

/// `cipherDecryptionFailure()` returns an `Alert` to notify the user that an item in their
/// vault was unable to be decrypted for when a cipher which failed to decrypt is tapped.
func test_cipherDecryptionFailure() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ protocol AttachmentPreviewHelper { // sourcery: AutoMockable
/// - Parameters:
/// - attachment: The attachment to preview.
/// - cipher: The cipher that owns the attachment.
/// - handleNavigateToPremiumUpgrade: A closure called to navigate to the Premium upgrade flow.
func showPreview(
for attachment: AttachmentView,
cipher: CipherView,
handleNavigateToPremiumUpgrade: @escaping () async -> Void,
) async
}

Expand Down Expand Up @@ -54,7 +56,15 @@ class DefaultAttachmentPreviewHelper: AttachmentPreviewHelper {
func showPreview(
for attachment: AttachmentView,
cipher: CipherView,
handleNavigateToPremiumUpgrade: @escaping () async -> Void,
) async {
guard await services.vaultRepository.doesActiveAccountHavePremium() else {
coordinator.showAlert(.attachmentPreviewUnavailable {
await handleNavigateToPremiumUpgrade()
})
return
}

if let sizeName = attachment.sizeName,
let size = Int(attachment.size ?? ""),
size >= Constants.largeFileSize {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,47 @@ struct AttachmentPreviewHelperTests {

// MARK: Tests

/// `showPreview(for:cipher:handleNavigateToPremiumUpgrade:)` shows the Premium-required alert
/// and doesn't download the attachment when the active account lacks Premium.
@Test
func showPreview_noPremium() async throws {
vaultRepository.doesActiveAccountHavePremiumResult = false
let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small")
let cipher = CipherView.loginFixture()

await subject.showPreview(for: attachment, cipher: cipher) {}

#expect(coordinator.alertShown.last == .attachmentPreviewUnavailable(action: {}))
#expect(vaultRepository.downloadAttachmentAttachment == nil)
#expect(coordinator.routes.isEmpty)
}

/// `showPreview(for:cipher:handleNavigateToPremiumUpgrade:)` calls the provided closure when
/// the user taps the upgrade action on the Premium-required alert.
@Test
func showPreview_noPremium_tapUpgrade() async throws {
vaultRepository.doesActiveAccountHavePremiumResult = false
let attachment = AttachmentView.fixture(fileName: "photo.png", size: "10", sizeName: "small")
let cipher = CipherView.loginFixture()
var handleNavigateToPremiumUpgradeCalled = false

await subject.showPreview(for: attachment, cipher: cipher) {
handleNavigateToPremiumUpgradeCalled = true
}

let alert = try #require(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.upgradeToPremium)
#expect(handleNavigateToPremiumUpgradeCalled)
}

/// `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)
await subject.showPreview(for: attachment, cipher: cipher) {}

let alert = try #require(coordinator.alertShown.last)
#expect(alert.title == Localizations.attachmentLargeWarning("big"))
Expand All @@ -67,7 +100,7 @@ struct AttachmentPreviewHelperTests {
let attachment = AttachmentView.fixture(fileName: "photo.png", size: "11000000", sizeName: "big")
let cipher = CipherView.loginFixture()

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

let alert = try #require(coordinator.alertShown.last)
try await alert.tapAction(title: Localizations.no)
Expand All @@ -83,7 +116,7 @@ struct AttachmentPreviewHelperTests {
let cipher = CipherView.loginFixture()
vaultRepository.downloadAttachmentResult = try .success(writeTemporaryImage())

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

#expect(coordinator.alertShown.isEmpty)
#expect(vaultRepository.downloadAttachmentAttachment == attachment)
Expand All @@ -98,7 +131,7 @@ struct AttachmentPreviewHelperTests {
let temporaryUrl = try writeTemporaryImage()
vaultRepository.downloadAttachmentResult = .success(temporaryUrl)

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

guard case let .attachmentPreview(state) = coordinator.routes.last else {
Issue.record("Expected a navigation to .attachmentPreview")
Expand All @@ -124,7 +157,7 @@ struct AttachmentPreviewHelperTests {
let temporaryUrl = try writeTemporaryFile(data: Data("not an image".utf8))
vaultRepository.downloadAttachmentResult = .success(temporaryUrl)

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

guard case let .attachmentPreview(state) = coordinator.routes.last else {
Issue.record("Expected a navigation to .attachmentPreview")
Expand All @@ -142,7 +175,7 @@ struct AttachmentPreviewHelperTests {
let temporaryUrl = try writeTemporaryFile(data: Data("%PDF-1.4".utf8))
vaultRepository.downloadAttachmentResult = .success(temporaryUrl)

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

guard case let .attachmentPreview(state) = coordinator.routes.last else {
Issue.record("Expected a navigation to .attachmentPreview")
Expand All @@ -159,7 +192,7 @@ struct AttachmentPreviewHelperTests {
let cipher = CipherView.loginFixture()
vaultRepository.downloadAttachmentResult = .success(nil)

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

#expect(coordinator.alertShown.last == .defaultAlert(title: Localizations.unableToDownloadFile))
#expect(coordinator.routes.isEmpty)
Expand All @@ -173,7 +206,7 @@ struct AttachmentPreviewHelperTests {
let cipher = CipherView.loginFixture()
vaultRepository.downloadAttachmentResult = .failure(BitwardenTestError.example)

await subject.showPreview(for: attachment, cipher: cipher)
await subject.showPreview(for: attachment, cipher: cipher) {}

#expect(coordinator.alertShown.last == .defaultAlert(title: Localizations.unableToDownloadFile))
#expect(coordinator.routes.isEmpty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,12 @@ final class ViewItemProcessor: StateProcessor<ViewItemState, ViewItemAction, Vie
case let .downloadAttachment(attachment):
guard case let .data(cipherState) = state.loadingState else { return }
Task {
await attachmentPreviewHelper.showPreview(for: attachment, cipher: cipherState.cipher)
await attachmentPreviewHelper.showPreview(
for: attachment,
cipher: cipherState.cipher,
) { [weak self] in
await self?.navigateToPremiumUpgrade()
}
}
case let .driversLicenseItemAction(action):
handleDriversLicenseAction(action)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1455,6 +1455,26 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type
XCTAssertEqual(attachmentPreviewHelper.showPreviewReceivedArguments?.cipher, cipher)
}

/// `.receive(_:)` with `.downloadAttachment(_)` delegates to the Premium upgrade helper.
@MainActor
func test_receive_downloadAttachment_navigateToPremiumUpgrade() throws {
let attachment = AttachmentView.fixture(size: "11000000", sizeName: "big")
let cipher = CipherView.fixture(attachments: [attachment])
let state = try XCTUnwrap(CipherItemState(existing: cipher, hasPremium: false))
subject.state.loadingState = .data(state)

subject.receive(.downloadAttachment(attachment))

waitFor(attachmentPreviewHelper.showPreviewCalled)
let handleNavigateToPremiumUpgrade = try XCTUnwrap(
attachmentPreviewHelper.showPreviewReceivedArguments?.handleNavigateToPremiumUpgrade,
)

Task { await handleNavigateToPremiumUpgrade() }

waitFor(premiumUpgradeHelper.navigateToPremiumUpgradeCalled)
}

/// `receive` with `.editPressed` has no change when the state is loading.
@MainActor
func test_receive_editPressed_loading() {
Expand Down
Loading