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