From 835f8737e45ac9790a72af377cce711a8414a577 Mon Sep 17 00:00:00 2001 From: Morgan Zellers Date: Mon, 17 Aug 2026 16:40:32 -0500 Subject: [PATCH] [PM-33413] feat: Gate attachment preview/download behind Premium subscription Viewing and downloading existing attachments was previously ungated, unlike uploading new attachments. This adds the same Premium check to AttachmentPreviewHelper.showPreview, showing an upgrade alert instead of downloading when the active account lacks Premium. --- .../en.lproj/Localizable.strings | 1 + .../UI/Vault/Extensions/Alert+Vault.swift | 23 +++++++++ .../UI/Vault/Extensions/AlertVaultTests.swift | 33 +++++++++++++ .../VaultItem/AttachmentPreviewHelper.swift | 10 ++++ .../AttachmentPreviewHelperTests.swift | 49 ++++++++++++++++--- .../ViewItem/ViewItemProcessor.swift | 7 ++- .../ViewItem/ViewItemProcessorTests.swift | 20 ++++++++ 7 files changed, 134 insertions(+), 9 deletions(-) diff --git a/BitwardenResources/Localizations/en.lproj/Localizable.strings b/BitwardenResources/Localizations/en.lproj/Localizable.strings index 6e5658d5ed..411c402e94 100644 --- a/BitwardenResources/Localizations/en.lproj/Localizable.strings +++ b/BitwardenResources/Localizations/en.lproj/Localizable.strings @@ -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"; diff --git a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift index 54fff73a17..fa97da8722 100644 --- a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift +++ b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift @@ -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. /// diff --git a/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift b/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift index f99fb9f743..b516809d65 100644 --- a/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift +++ b/BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift @@ -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 { diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift index 64a3de9050..4c7c4cb984 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelper.swift @@ -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 } @@ -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 { diff --git a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift index b16f38776f..876eef3948 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AttachmentPreviewHelperTests.swift @@ -37,6 +37,39 @@ 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 @@ -44,7 +77,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) #expect(alert.title == Localizations.attachmentLargeWarning("big")) @@ -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) @@ -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) @@ -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") @@ -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") @@ -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") @@ -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) @@ -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) diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift index 8b78946ca3..628a30105e 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift @@ -200,7 +200,12 @@ final class ViewItemProcessor: StateProcessor