[PM-36251] feat: Navigate archive premium upsell through in-app upgrade flow#2607
[PM-36251] feat: Navigate archive premium upsell through in-app upgrade flow#2607andrebispo5 wants to merge 9 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Routes the “Archive unavailable” upsell CTA through the same premium in-app upgrade flow as the vault list banner (with a web vault URL fallback when in-app upgrade isn’t available), and refactors premium-upgrade banner eligibility vs. dismissal into distinct concerns.
Changes:
- Split premium upgrade logic into eligibility (
isPremiumUpgradeEligible) vs. banner dismissal (isPremiumUpgradeBannerDismissed). - Centralized in-app upgrade availability checks and navigation fallback logic inside
VaultListProcessor. - Updated/added tests to cover banner dismissal behavior and archive CTA routing (in-app vs web fallback).
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift | Refactors premium upgrade banner visibility logic and routes archive upsell to in-app upgrade when available, otherwise web URL fallback. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift | Updates banner tests for new eligibility/dismissal split; adds archive CTA routing tests. |
| BitwardenShared/Core/Platform/Services/StateService.swift | Updates StateService API by replacing shouldShowPremiumUpgradeBanner() with eligibility + dismissal methods and implements them in DefaultStateService. |
| BitwardenShared/Core/Platform/Services/StateServiceTests.swift | Renames/adjusts tests for the new StateService API and adds dismissal-specific tests. |
| BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift | Updates the mock to match the new StateService API used by tests/processors. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let alert = coordinator.alertShown.last | ||
| try? await alert?.tapAction(title: Localizations.upgradeToPremium) | ||
| try await waitForAsync { self.coordinator.routes.last == .premiumUpgrade } |
There was a problem hiding this comment.
Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.
| let alert = coordinator.alertShown.last | ||
| XCTAssertEqual(alert?.title, Localizations.archiveUnavailable) | ||
| XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong) | ||
|
|
||
| try? await alert?.tapAction(title: Localizations.upgradeToPremium) |
There was a problem hiding this comment.
Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.
| let alert = coordinator.alertShown.last | |
| XCTAssertEqual(alert?.title, Localizations.archiveUnavailable) | |
| XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong) | |
| try? await alert?.tapAction(title: Localizations.upgradeToPremium) | |
| let alert = try XCTUnwrap(coordinator.alertShown.last) | |
| XCTAssertEqual(alert.title, Localizations.archiveUnavailable) | |
| XCTAssertEqual(alert.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong) | |
| try await alert.tapAction(title: Localizations.upgradeToPremium) |
| @@ -2672,12 +2672,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body | |||
| ))) | |||
| appSettingsStore.premiumUpgradeBannerDismissedByUserId["1"] = false | |||
|
|
|||
| let shouldShow = await subject.shouldShowPremiumUpgradeBanner() | |||
| let shouldShow = await subject.isPremiumUpgradeEligible() | |||
| XCTAssertTrue(shouldShow) | |||
There was a problem hiding this comment.
The doc comment for test_isPremiumUpgradeEligible_true still says eligibility requires the banner to not be dismissed, but isPremiumUpgradeEligible() no longer checks dismissal (that’s covered by isPremiumUpgradeBannerDismissed()). Please update the comment (and consider renaming shouldShow to isEligible for clarity).
| /// `receive(_:)` with `.itemPressed` navigates to the premium upgrade screen even when the | ||
| /// banner has been dismissed, since the archive entry point bypasses the dismissal check. | ||
| @MainActor | ||
| func test_receive_itemPressed_archiveGroup_noPremium_noItems_actionTapped_bannerDismissed() async throws { | ||
| configService.featureFlagsBool[.premiumUpgradePath] = true | ||
| stateService.isPremiumUpgradeEligibleResult = true | ||
| vaultRepository.hasMinimumCipherCountResult = .success(true) | ||
| storefrontService.isUSStorefrontReturnValue = true | ||
| let statusSubject = PassthroughSubject<PremiumCheckoutStatus, Never>() | ||
| billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher() |
There was a problem hiding this comment.
This test claims the archive upgrade flow should work even when the banner is dismissed, but it never sets stateService.isPremiumUpgradeBannerDismissedResult = true. As written it doesn't actually verify the dismissal-bypass behavior.
| let alert = coordinator.alertShown.last | ||
| XCTAssertEqual(alert?.title, Localizations.archiveUnavailable) | ||
| XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong) | ||
|
|
||
| XCTAssertTrue(vaultRepository.archiveCipher.isEmpty) | ||
| try? await alert?.tapAction(title: Localizations.upgradeToPremium) | ||
| try await waitForAsync { self.coordinator.routes.last == .premiumUpgrade } |
There was a problem hiding this comment.
Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #2607 +/- ##
==========================================
+ Coverage 87.20% 87.22% +0.02%
==========================================
Files 1895 1898 +3
Lines 167767 167986 +219
==========================================
+ Hits 146304 146531 +227
+ Misses 21463 21455 -8 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| func isPremiumUpgradeBannerDismissed() async -> Bool | ||
|
|
||
| /// Returns whether the user meets the eligibility criteria for the premium upgrade | ||
| /// (free account and 7+ days old). |
There was a problem hiding this comment.
⛏️ I wouldn't say which are the requirements here in the protocol as if they change you need to remember updating this comment as well. Also the caller doesn't need to know which are the requirements just what the function does and returns.
So for easier maintainability I'd just remove this line
| /// (free account and 7+ days old). |
| } | ||
|
|
||
| func isPremiumUpgradeBannerDismissed() async -> Bool { | ||
| await ((try? getPremiumUpgradeBannerDismissed()) ?? false) |
There was a problem hiding this comment.
⛏️ I think you can drop the outer parenthesis here:
| await ((try? getPremiumUpgradeBannerDismissed()) ?? false) | |
| await (try? getPremiumUpgradeBannerDismissed()) ?? false |
🤔 Also, I think you should log the error if getPremiumUpgradeBannerDismissed throws and then return false instead of of using try? here. Or is it fine to ignore the errors on this function?
| guard await !doesActiveAccountHavePremium() else { return false } | ||
|
|
||
| // Check account age >= 7 days | ||
| guard let account = try? await getActiveAccount(), |
There was a problem hiding this comment.
🤔 Shouldn't an error be logged if getActiveAccount throws or is it safe to ignore here?
There was a problem hiding this comment.
it will fail in doesActiveAccountHavePremium the call above and return false
| guard timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge else { | ||
| return false | ||
| } | ||
|
|
||
| return true |
There was a problem hiding this comment.
🎨 Can't you just return the operation result here?
| guard timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge else { | |
| return false | |
| } | |
| return true | |
| return timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge |
| guard (try? await services.vaultRepository | ||
| .hasMinimumCipherCount(Constants.minimumPremiumUpgradeBannerCipherCount)) ?? false |
There was a problem hiding this comment.
🤔 Shouldn't this be logging the error when hasMinimumCipherCount throws instead of ignoring it?
| guard (try? await services.vaultRepository | ||
| .hasMinimumCipherCount(Constants.minimumPremiumUpgradeBannerCipherCount)) ?? false | ||
| else { return false } | ||
| guard await services.storefrontService.isUSStorefront() else { return false } |
There was a problem hiding this comment.
🎨 IMO this services.storefrontService.isUSStorefront should be checked before hasMinimumCipherCount or even services.stateService.isPremiumUpgradeEligible as it should be faster and consume less resources than the others, as it's just a cached value.
| if await isInAppUpgradeAvailable() { | ||
| subscribeToPremiumCheckoutStatus() | ||
| coordinator.navigate(to: .premiumUpgrade) | ||
| } else { | ||
| state.url = services.environmentService.upgradeToPremiumURL | ||
| } |
There was a problem hiding this comment.
⛏️ I'd use a guard here to avoid the nesting blocks and be more "Swifty":
| if await isInAppUpgradeAvailable() { | |
| subscribeToPremiumCheckoutStatus() | |
| coordinator.navigate(to: .premiumUpgrade) | |
| } else { | |
| state.url = services.environmentService.upgradeToPremiumURL | |
| } | |
| guard await isInAppUpgradeAvailable() else { | |
| state.url = services.environmentService.upgradeToPremiumURL | |
| return | |
| } | |
| subscribeToPremiumCheckoutStatus() | |
| coordinator.navigate(to: .premiumUpgrade) |
| let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed() | ||
| let isUpgradeAvailable = await isInAppUpgradeAvailable() | ||
| state.shouldShowPremiumUpgradeActionCard = !isBannerDismissed && isUpgradeAvailable |
There was a problem hiding this comment.
🎨 If the banner has already been dismissed then it's wasteful to check for isInAppUpgradeAvailable as that value doesn't really matter later. So I would rewrite this with short-circuit as:
let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
guard !isBannerDismissed else {
state.shouldShowPremiumUpgradeActionCard = false
return
}
state.shouldShowPremiumUpgradeActionCard = await isInAppUpgradeAvailable()or like this:
let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
state.shouldShowPremiumUpgradeActionCard = !isBannerDismissed && (await isInAppUpgradeAvailable())
🤖 Bitwarden Claude Code ReviewOverall Assessment: REQUEST CHANGES This PR refactors the premium upgrade banner eligibility logic, introduces a new Code Review Details
|
| coordinator.showAlert( | ||
| Alert.archiveUnavailable(action: { [weak self] in | ||
| guard let self else { return } | ||
| state.url = services.environmentService.upgradeToPremiumURL | ||
| Task { [weak self] in | ||
| await self?.navigateToPremiumUpgrade() | ||
| } |
There was a problem hiding this comment.
❓ QUESTION: The same Alert.archiveUnavailable is also shown from VaultItemMoreOptionsHelper.archive() (when a non-premium user taps Archive on an individual cipher), but that path still goes straight to services.environmentService.upgradeToPremiumURL and bypasses the new in-app upgrade flow.
Details
BitwardenShared/UI/Vault/Helpers/VaultItemMoreOptionsHelper.swift:131-134:
Alert.archiveUnavailable(action: { [weak self] in
guard let self else { return }
handleOpenURL(services.environmentService.upgradeToPremiumURL)
}),Was the more-options entry point intentionally left out of scope, or should it be migrated to the same navigateToPremiumUpgrade() path so that the identical alert routes consistently from both entry points?
| coordinator.showAlert( | ||
| Alert.archiveUnavailable(action: { [weak self] in | ||
| guard let self else { return } | ||
| handleOpenURL(services.environmentService.upgradeToPremiumURL) | ||
| Task { [weak self] in | ||
| guard let self else { return } | ||
| if await self.services.billingRepository.isInAppUpgradeAvailable() { | ||
| self.coordinator.navigate(to: .premiumUpgrade) | ||
| } else { | ||
| handleOpenURL(self.services.environmentService.upgradeToPremiumURL) | ||
| } | ||
| } | ||
| }), | ||
| ) |
There was a problem hiding this comment.
.premiumUpgrade without subscribing to premiumCheckoutStatusPublisher, so a successful checkout never dismisses the upgrade screen.
Details and fix
PremiumUpgradeProcessor.subscribeToPremiumCheckoutStatus only handles .canceled itself; for .confirmed, .pending, and .syncing it relies on the parent owning the dismiss (see the explicit comment in PremiumUpgradeProcessor.swift:120-122: "VaultListProcessor owns the dismiss and all post-dismiss actions via DismissAction for each of these states.").
VaultListProcessor.navigateToPremiumUpgrade() calls subscribeToPremiumCheckoutStatus() before navigating, but the new code path in this helper does not. Concrete consequences when in-app upgrade is available and the user completes checkout from the archive more-options entry:
- The premium upgrade modal does not dismiss after
.confirmed(noDismissAction). - The vault is not refreshed and
state.hasPremiumis not updated, so the archive group still appears unavailable. - The "upgrade pending" alert is not shown for
.pending. - The confirming-loading overlay is not shown for
.syncing.
VaultItemMoreOptionsHelper is a @MainActor helper, not a StateProcessor, so it cannot reasonably own the dismiss/refresh logic. The cleanest fix is to keep the upsell routing centralized in VaultListProcessor — either by surfacing this through the existing .upgradeToPremium action, or by exposing a coordinator/delegate hook so the processor can subscribe before navigation.
Reference: BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift:117-124, BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift:562-569.
🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-36251
📔 Objective
Wires the archive unavailable upsell CTA to the same in-app premium upgrade flow used by the vault list banner, instead of always opening the web vault URL.
shouldShowPremiumUpgradeBanner→isPremiumUpgradeEligibleto better reflect that it checks user eligibility, not banner visibility.isPremiumUpgradeBannerDismissedas a separateStateServicemethod so banner dismissal is a distinct concern.isInAppUpgradeAvailable()andnavigateToPremiumUpgrade()toVaultListProcessor, encapsulating the routing decision (in-app upgrade flow vs. web vault fallback) based on the feature flag and US storefront.