[PM-29638] feat: Archive vault group list premium expired card (#2280)

This commit is contained in:
Federico Maccaroni 2026-01-23 13:54:54 -03:00 committed by GitHub
parent 450a87427f
commit 30a494b5c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 280 additions and 6 deletions

View File

@ -1300,3 +1300,7 @@
"ArchivingItemsIsAPremiumFeatureDescriptionLong" = "Archiving items 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";
"YourPremiumSubscriptionEndedArchiveDescriptionLong" = "To regain access to your archive, restart your Premium subscription. If you edit details for an archived item before restarting, itll be moved back into your vault.";
"RestartPremium" = "Restart Premium";
"ThisItemIsArchivedSavingChangesWillRestoreItToYourVault" = "This item is archived. Saving changes will restore it to your vault.";

View File

@ -774,6 +774,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
collectionHelper: collectionHelper,
configService: configService,
errorReporter: errorReporter,
stateService: stateService,
),
vaultListDataPreparator: DefaultVaultListDataPreparator(
cipherMatchingHelperFactory: DefaultCipherMatchingHelperFactory(

View File

@ -188,6 +188,7 @@ class VaultListSectionsBuilderCollectionTests: BitwardenTestCase {
collectionHelper: collectionHelper,
configService: MockConfigService(),
errorReporter: errorReporter,
stateService: MockStateService(),
withData: withData,
)
}

View File

@ -275,6 +275,7 @@ class VaultListSectionsBuilderFolderTests: BitwardenTestCase {
collectionHelper: collectionHelper,
configService: MockConfigService(),
errorReporter: errorReporter,
stateService: MockStateService(),
withData: withData,
)
}

View File

@ -104,6 +104,8 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d
let errorReporter: ErrorReporter
/// Vault list data prepared to be used by the builder.
var preparedData: VaultListPreparedData
/// The service used by the application to manage account state.
let stateService: StateService
/// The vault list data to build.
private var vaultListData = VaultListData()
@ -116,11 +118,13 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - preparedData: `VaultListPreparedData` to be used as input to build the sections where the caller
/// decides which to include depending on the builder methods called.
/// - stateService: The service used by the application to manage account state.
init(
clientService: ClientService,
collectionHelper: CollectionHelper,
configService: ConfigService,
errorReporter: ErrorReporter,
stateService: StateService,
withData preparedData: VaultListPreparedData,
) {
self.clientService = clientService
@ -128,6 +132,7 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d
self.configService = configService
self.errorReporter = errorReporter
self.preparedData = preparedData
self.stateService = stateService
}
// MARK: Methods
@ -328,8 +333,13 @@ class DefaultVaultListSectionsBuilder: VaultListSectionsBuilder { // swiftlint:d
func addHiddenItemsSection() async -> VaultListSectionsBuilder {
var items: [VaultListItem] = []
let hasPremium = await stateService.doesActiveAccountHavePremium()
if await configService.getFeatureFlag(.archiveVaultItems) {
items.append(VaultListItem(id: "Archive", itemType: .group(.archive, preparedData.ciphersArchivedCount)))
if hasPremium || preparedData.ciphersArchivedCount > 0 {
items.append(
VaultListItem(id: "Archive", itemType: .group(.archive, preparedData.ciphersArchivedCount)),
)
}
}
items.append(VaultListItem(id: "Trash", itemType: .group(.trash, preparedData.ciphersDeletedCount)))

View File

@ -23,6 +23,8 @@ struct DefaultVaultListSectionsBuilderFactory: VaultListSectionsBuilderFactory {
let configService: ConfigService
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The service used by the application to manage account state.
let stateService: StateService
func make(withData preparedData: VaultListPreparedData) -> VaultListSectionsBuilder {
DefaultVaultListSectionsBuilder(
@ -30,6 +32,7 @@ struct DefaultVaultListSectionsBuilderFactory: VaultListSectionsBuilderFactory {
collectionHelper: collectionHelper,
configService: configService,
errorReporter: errorReporter,
stateService: stateService,
withData: preparedData,
)
}

View File

@ -29,6 +29,7 @@ class VaultListSectionsBuilderFactoryTests: BitwardenTestCase {
collectionHelper: collectionHelper,
configService: configService,
errorReporter: errorReporter,
stateService: MockStateService(),
)
}

View File

@ -14,6 +14,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
var clientService: MockClientService!
var configService: MockConfigService!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: DefaultVaultListSectionsBuilder!
// MARK: Setup & Teardown
@ -24,6 +25,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
clientService = MockClientService()
configService = MockConfigService()
errorReporter = MockErrorReporter()
stateService = MockStateService()
}
override func tearDown() {
@ -32,6 +34,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
clientService = nil
configService = nil
errorReporter = nil
stateService = nil
subject = nil
}
@ -382,11 +385,12 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
}
}
/// `addHiddenItemsSection()` adds the hidden items section to the list of sections with the count of
/// archived and deleted ciphers when the archive feature flag is on.
/// `addHiddenItemsSection()` adds the hidden items section with archive when the feature flag is on
/// and the user has premium.
@MainActor
func test_addHiddenItemsSection_archiveFeatureFlagEnabled() async {
func test_addHiddenItemsSection_archiveFeatureFlagEnabled_hasPremium() async {
configService.featureFlagsBool[.archiveVaultItems] = true
stateService.doesActiveAccountHavePremiumResult = true
setUpSubject(withData: VaultListPreparedData(
ciphersArchivedCount: 5,
ciphersDeletedCount: 10,
@ -403,6 +407,49 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
}
}
/// `addHiddenItemsSection()` does not add archive when the feature flag is on but the user
/// does not have premium and there are no archived items.
@MainActor
func test_addHiddenItemsSection_archiveFeatureFlagEnabled_noPremium_noArchivedItems() async {
configService.featureFlagsBool[.archiveVaultItems] = true
stateService.doesActiveAccountHavePremiumResult = false
setUpSubject(withData: VaultListPreparedData(
ciphersArchivedCount: 0,
ciphersDeletedCount: 10,
))
let vaultListData = await subject.addHiddenItemsSection().build()
assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) {
"""
Section[HiddenItems]: Hidden items
- Group[Trash]: Trash (10)
"""
}
}
/// `addHiddenItemsSection()` adds archive when the feature flag is on and the user
/// does not have premium but there are archived items (grandfathered).
@MainActor
func test_addHiddenItemsSection_archiveFeatureFlagEnabled_noPremium_hasArchivedItems() async {
configService.featureFlagsBool[.archiveVaultItems] = true
stateService.doesActiveAccountHavePremiumResult = false
setUpSubject(withData: VaultListPreparedData(
ciphersArchivedCount: 3,
ciphersDeletedCount: 10,
))
let vaultListData = await subject.addHiddenItemsSection().build()
assertInlineSnapshot(of: vaultListData.sections.dump(), as: .lines) {
"""
Section[HiddenItems]: Hidden items
- Group[Archive]: Archive (3)
- Group[Trash]: Trash (10)
"""
}
}
/// `addTOTPSection()` adds the TOTP section with an item when there are TOTP items.
func test_addTOTPSection() {
setUpSubject(
@ -780,6 +827,7 @@ class VaultListSectionsBuilderTests: BitwardenTestCase { // swiftlint:disable:th
collectionHelper: collectionHelper,
configService: configService,
errorReporter: errorReporter,
stateService: stateService,
withData: withData,
)
}

View File

@ -24,6 +24,9 @@ enum VaultGroupAction: Equatable, Sendable {
///
case itemPressed(_ item: VaultListItem)
/// The user tapped in "Restart Premium" subscription for archive.
case restartPremiumSubscription
/// The user has started or stopped searching.
case searchStateChanged(isSearching: Bool)

View File

@ -15,6 +15,7 @@ final class VaultGroupProcessor: StateProcessor<
typealias Services = HasAuthRepository
& HasConfigService
& HasEnvironmentService
& HasErrorReporter
& HasEventService
& HasPasteboardService
@ -109,6 +110,7 @@ final class VaultGroupProcessor: StateProcessor<
override func perform(_ effect: VaultGroupEffect) async {
switch effect {
case .appeared:
await loadHasPremiumAccount()
await checkPersonalOwnershipPolicy()
await loadItemTypesUserCanCreate()
await streamVaultList()
@ -160,6 +162,8 @@ final class VaultGroupProcessor: StateProcessor<
case let .totp(_, model):
navigateToViewItem(cipherListView: model.cipherListView, id: model.id)
}
case .restartPremiumSubscription:
state.url = services.environmentService.upgradeToPremiumURL
case let .searchStateChanged(isSearching):
if !isSearching {
state.searchText = ""
@ -190,6 +194,12 @@ final class VaultGroupProcessor: StateProcessor<
state.canShowVaultFilter = await services.vaultRepository.canShowVaultFilter()
}
/// Loads whether the current account has premium subscription.
///
private func loadHasPremiumAccount() async {
state.hasPremium = await services.stateService.doesActiveAccountHavePremium()
}
/// Checks available item types user can create.
///
private func loadItemTypesUserCanCreate() async {

View File

@ -186,6 +186,19 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
XCTAssertEqual(errorReporter.errors.last as? BitwardenTestError, .example)
}
/// `perform(_:)` with `.appeared` updates the state depending on if the user has premium account.
@MainActor
func test_perform_appeared_hasPremiumAccount() {
stateService.doesActiveAccountHavePremiumResult = true
let task = Task {
await subject.perform(.appeared)
}
defer { task.cancel() }
waitFor(subject.state.hasPremium)
}
/// `perform(_:)` with `.appeared` updates the state depending on if the
/// personal ownership policy is enabled.
@MainActor

View File

@ -67,6 +67,9 @@ struct VaultGroupState: Equatable, Sendable {
/// The `VaultListGroup` being displayed.
var group: VaultListGroup = .login
/// Whether the user has a premium account.
var hasPremium: Bool = false
/// The base url used to fetch icons.
var iconBaseURL: URL?
@ -130,6 +133,11 @@ struct VaultGroupState: Equatable, Sendable {
/// The search vault filter used to display a single or all vaults for the user.
var searchVaultFilterType = VaultFilterType.allVaults
/// Whether the archive premium subscription ended card should be shown.
var showArchivePremiumSubscriptionEndedCard: Bool {
!hasPremium && group == .archive
}
/// Whether to show the special web icons.
var showWebIcons = true

View File

@ -130,4 +130,36 @@ class VaultGroupStateTests: BitwardenTestCase {
let subjectTotp = VaultGroupState(group: .totp, vaultFilterType: .myVault)
XCTAssertNil(subjectTotp.noItemsTitle)
}
/// `showArchivePremiumSubscriptionEndedCard` returns `true` when the user doesn't have premium
/// and is viewing the archive group.
func test_showArchivePremiumSubscriptionEndedCard() {
let subjectNoPremiumArchive = VaultGroupState(
group: .archive,
hasPremium: false,
vaultFilterType: .myVault
)
XCTAssertTrue(subjectNoPremiumArchive.showArchivePremiumSubscriptionEndedCard)
let subjectHasPremiumArchive = VaultGroupState(
group: .archive,
hasPremium: true,
vaultFilterType: .myVault
)
XCTAssertFalse(subjectHasPremiumArchive.showArchivePremiumSubscriptionEndedCard)
let subjectNoPremiumLogin = VaultGroupState(
group: .login,
hasPremium: false,
vaultFilterType: .myVault
)
XCTAssertFalse(subjectNoPremiumLogin.showArchivePremiumSubscriptionEndedCard)
let subjectHasPremiumLogin = VaultGroupState(
group: .login,
hasPremium: true,
vaultFilterType: .myVault
)
XCTAssertFalse(subjectHasPremiumLogin.showArchivePremiumSubscriptionEndedCard)
}
}

View File

@ -178,6 +178,50 @@ class VaultGroupViewTests: BitwardenTestCase {
assertSnapshot(of: subject, as: .defaultPortrait)
}
@MainActor
func disabletest_snapshot_oneArchivedItem_premium() {
processor.state.hasPremium = true
processor.state.group = .archive
processor.state.loadingState = .data(
[
VaultListSection(
id: "Items",
items: [
.fixture(cipherListView: .fixture(
login: .fixture(username: "email@example.com"),
name: "Example",
archivedDate: .now,
)),
],
name: Localizations.items,
),
],
)
assertSnapshot(of: subject, as: .defaultPortrait)
}
@MainActor
func disabletest_snapshot_oneArchivedItem_noPremium() {
processor.state.hasPremium = false
processor.state.group = .archive
processor.state.loadingState = .data(
[
VaultListSection(
id: "Items",
items: [
.fixture(cipherListView: .fixture(
login: .fixture(username: "email@example.com"),
name: "Example",
archivedDate: .now,
)),
],
name: Localizations.items,
),
],
)
assertSnapshot(of: subject, as: .defaultPortrait)
}
@MainActor
func disabletest_snapshot_search_oneItem() {
processor.state.isSearching = true

View File

@ -55,6 +55,19 @@ struct VaultGroupView: View {
// MARK: Private
/// The action card for premium subscription ended for archive.
@ViewBuilder private var archivePremiumSubscriptionEndedCard: some View {
if store.state.showArchivePremiumSubscriptionEndedCard {
ActionCard(
title: Localizations.yourPremiumSubscriptionEnded,
message: Localizations.yourPremiumSubscriptionEndedArchiveDescriptionLong,
actionButtonState: ActionCard.ButtonState(title: Localizations.restartPremium) {
store.send(.restartPremiumSubscription)
},
)
}
}
@ViewBuilder private var content: some View {
searchOrGroup
.onChange(of: store.state.url) { newValue in
@ -205,6 +218,8 @@ struct VaultGroupView: View {
@ViewBuilder
private func groupView(with sections: [VaultListSection]) -> some View {
VStack(spacing: 16) {
archivePremiumSubscriptionEndedCard
ForEach(sections) { section in
VaultListSectionView(section: section) { item in
Button {

View File

@ -20,6 +20,7 @@ final class VaultListProcessor: StateProcessor<
& HasAuthRepository
& HasAuthService
& HasChangeKdfService
& HasConfigService
& HasErrorReporter
& HasEventService
& HasFlightRecorder
@ -221,7 +222,10 @@ extension VaultListProcessor {
await checkPendingLoginRequests()
await checkPersonalOwnershipPolicy()
await loadItemTypesUserCanCreate()
state.shouldShowArchiveOnboardingActionCard = await services.stateService.shouldDoArchiveOnboarding()
if await services.configService.getFeatureFlag(.archiveVaultItems) {
state.shouldShowArchiveOnboardingActionCard = await services.stateService.shouldDoArchiveOnboarding()
}
}
/// Checks if the user needs to update their KDF settings.

View File

@ -20,6 +20,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
var authRepository: MockAuthRepository!
var authService: MockAuthService!
var changeKdfService: MockChangeKdfService!
var configService: MockConfigService!
var coordinator: MockCoordinator<VaultRoute, AuthAction>!
var errorReporter: MockErrorReporter!
var flightRecorder: MockFlightRecorder!
@ -49,6 +50,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
authService = MockAuthService()
errorReporter = MockErrorReporter()
changeKdfService = MockChangeKdfService()
configService = MockConfigService()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
flightRecorder = MockFlightRecorder()
@ -71,6 +73,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
authRepository: authRepository,
authService: authService,
changeKdfService: changeKdfService,
configService: configService,
errorReporter: errorReporter,
flightRecorder: flightRecorder,
notificationService: notificationService,
@ -98,6 +101,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
authRepository = nil
authService = nil
changeKdfService = nil
configService = nil
coordinator = nil
errorReporter = nil
flightRecorder = nil
@ -540,6 +544,7 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// `perform(_:)` with `.appeared` updates whether to show the archive onboarding card.
@MainActor
func test_perform_appeared_loadArchiveOnboarding() async {
configService.featureFlagsBool[.archiveVaultItems] = true
stateService.doesActiveAccountHavePremiumResult = true
stateService.archiveOnboardingShown = false
@ -548,6 +553,19 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertTrue(subject.state.shouldShowArchiveOnboardingActionCard)
}
/// `perform(_:)` with `.appeared` doesn't update whether to show the archive onboarding card
/// when archive FF is turned off.
@MainActor
func test_perform_appeared_loadArchiveOnboarding_FFOff() async {
configService.featureFlagsBool[.archiveVaultItems] = false
stateService.doesActiveAccountHavePremiumResult = true
stateService.archiveOnboardingShown = false
await subject.perform(.appeared)
XCTAssertFalse(subject.state.shouldShowArchiveOnboardingActionCard)
}
/// `perform(_:)` with `.dismissArchiveOnboardingActionCard` dismisses the archive onboarding card
/// and sets the archive onboarding shown property to true.
@MainActor

View File

@ -8,6 +8,9 @@ import Foundation
protocol AddEditItemState: Sendable {
// MARK: Properties
/// The info text to display when item is archived.
var archiveInfoText: String { get }
/// The card item state.
var cardItemState: CardItemState { get set }

View File

@ -91,7 +91,7 @@ struct AddEditItemView: View {
}
if store.state.shouldDisplayAsArchived {
InfoContainer(text: Localizations.thisItemIsArchived, icon: SharedAsset.Icons.archive24)
InfoContainer(text: store.state.archiveInfoText, icon: SharedAsset.Icons.archive24)
.accessibilityIdentifier("ArchivedLabel")
}

View File

@ -145,6 +145,16 @@ struct CipherItemState: Equatable { // swiftlint:disable:this type_body_length
self
}
/// The info text to display when item is archived.
var archiveInfoText: String {
guard shouldDisplayAsArchived else {
return ""
}
return accountHasPremium
? Localizations.thisItemIsArchived
: Localizations.thisItemIsArchivedSavingChangesWillRestoreItToYourVault
}
/// Whether or not this item can be archived by the user.
var canBeArchived: Bool {
isArchiveVaultItemsFFEnabled && cipher.archivedDate == nil && cipher.deletedDate == nil

View File

@ -68,6 +68,51 @@ class CipherItemStateTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertTrue(state.loginState.isTOTPAvailable)
}
/// `archiveInfoText` returns nil when the item is not archived.
func test_archiveInfoText_notArchived() throws {
let state = try CipherItemState.initForArchive(archivedDate: nil)
XCTAssertEqual(state.archiveInfoText, "")
}
/// `archiveInfoText` returns nil when the feature flag is disabled.
func test_archiveInfoText_featureFlagDisabled() throws {
let state = try CipherItemState.initForArchive(
archivedDate: .now,
isArchiveVaultItemsFFEnabled: false,
)
XCTAssertEqual(state.archiveInfoText, "")
}
/// `archiveInfoText` returns nil when the item is deleted.
func test_archiveInfoText_deleted() throws {
let state = try CipherItemState.initForArchive(
archivedDate: .now,
deletedDate: .now,
)
XCTAssertEqual(state.archiveInfoText, "")
}
/// `archiveInfoText` returns the premium text when the item is archived and user has premium.
func test_archiveInfoText_archivedWithPremium() throws {
let state = try CipherItemState.initForArchive(
archivedDate: .now,
hasPremium: true,
)
XCTAssertEqual(state.archiveInfoText, Localizations.thisItemIsArchived)
}
/// `archiveInfoText` returns the non-premium text when the item is archived and user lacks premium.
func test_archiveInfoText_archivedWithoutPremium() throws {
let state = try CipherItemState.initForArchive(
archivedDate: .now,
hasPremium: false,
)
XCTAssertEqual(
state.archiveInfoText,
Localizations.thisItemIsArchivedSavingChangesWillRestoreItToYourVault
)
}
/// `canAssignToCollection` returns false if the user doesn't have access to any organizations.
func test_canAssignToCollection_noOrganizations() throws {
let cipher = CipherView.fixture(organizationId: nil)