[PM-29634] feat: Implement archive onboarding (#2277)

Co-authored-by: Katherine Bertelsen <kbertelsen@bitwarden.com>
This commit is contained in:
Federico Maccaroni 2026-01-23 11:58:09 -03:00 committed by GitHub
parent 9db668f0e2
commit 8cb3e7d59c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 270 additions and 64 deletions

View File

@ -180,6 +180,15 @@ public struct ActionCard<LeadingContent: View>: View {
dismissButtonState: ActionCard.ButtonState(title: "Dismiss") {},
)
ActionCard(
title: "Title",
message: "Message",
actionButtonState: ActionCard.ButtonState(title: "Tap me!") {},
dismissButtonState: ActionCard.ButtonState(title: "Dismiss") {},
) {
SharedAsset.Icons.warning24.swiftUIImage.foregroundStyle(SharedAsset.Colors.iconSecondary.swiftUIColor)
}
ActionCard(
title: "Title",
message: "Message",

View File

@ -1264,6 +1264,9 @@
"TheNewRecommendedEncryptionSettingsDescriptionLong" = "The new recommended encryption settings will improve your account security. Enter your master password to update now.";
"Updating" = "Updating…";
"EncryptionSettingsUpdated" = "Encryption settings updated";
"IntroducingArchive" = "Introducing Archive";
"KeepYtemsYouDontNeedRightNowSafeButOutOfSight" = "Keep items you dont need right now safe but out of sight.";
"GoToArchive" = "Go to archive";
"ItemTransfer" = "Item transfer";
"TransferItemsToX" = "Transfer items to %1$@";
"XIsRequiringAllItemsToBeOwnedByTheOrganizationDescriptionLong" = "%1$@ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.";

View File

@ -146,6 +146,12 @@ protocol StateService: AnyObject {
///
func getAppTheme() async -> AppTheme
/// Gets whether the archive onboarding has been shown.
///
/// - Returns: Whether the archive onboarding has been shown.
///
func getArchiveOnboardingShown() async -> Bool
/// Gets the clear clipboard value for an account.
///
/// - Parameter userId: The user ID associated with the clear clipboard value. Defaults to the active
@ -527,6 +533,12 @@ protocol StateService: AnyObject {
///
func setAppTheme(_ appTheme: AppTheme) async
/// Sets whether the archive onboarding has been shown.
///
/// - Parameter shown: Whether the archive onboarding has been shown.
///
func setArchiveOnboardingShown(_ shown: Bool) async
/// Sets the clear clipboard value for an account.
///
/// - Parameters:
@ -1382,6 +1394,14 @@ extension StateService {
func setVaultTimeout(value: SessionTimeoutValue) async throws {
try await setVaultTimeout(value: value, userId: nil)
}
/// Whether the user should do the archive onboarding.
/// - Returns: `true` if they should, `false` otherwise.
func shouldDoArchiveOnboarding() async -> Bool {
let hasPremium = await doesActiveAccountHavePremium()
let archiveOnboardingShown = await getArchiveOnboardingShown()
return hasPremium && !archiveOnboardingShown
}
}
// MARK: - StateServiceError
@ -1639,6 +1659,10 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
AppTheme(appSettingsStore.appTheme)
}
func getArchiveOnboardingShown() async -> Bool {
appSettingsStore.archiveOnboardingShown
}
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.clearClipboardValue(userId: userId)
@ -1992,6 +2016,10 @@ actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigState
appThemeSubject.send(appTheme)
}
func setArchiveOnboardingShown(_ shown: Bool) async {
appSettingsStore.archiveOnboardingShown = shown
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws {
let userId = try userId ?? getActiveAccountUserId()
appSettingsStore.setClearClipboardValue(clearClipboardValue, userId: userId)

View File

@ -622,12 +622,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
}
}
/// `getClearClipboardValue()` returns the clear clipboard value for the active account.
func test_getClearClipboardValue() async throws {
await subject.addAccount(.fixture())
appSettingsStore.clearClipboardValues["1"] = .twoMinutes
let value = try await subject.getClearClipboardValue()
XCTAssertEqual(value, .twoMinutes)
/// `getArchiveOnboardingShown()` returns whether the archive onboarding has been shown.
func test_getArchiveOnboardingShown() async {
var hasShownOnboarding = await subject.getArchiveOnboardingShown()
XCTAssertFalse(hasShownOnboarding)
appSettingsStore.archiveOnboardingShown = true
hasShownOnboarding = await subject.getArchiveOnboardingShown()
XCTAssertTrue(hasShownOnboarding)
}
/// `getBiometricAuthenticationEnabled(:)` returns biometric unlock preference of the active user.
@ -647,6 +649,14 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
}
}
/// `getClearClipboardValue()` returns the clear clipboard value for the active account.
func test_getClearClipboardValue() async throws {
await subject.addAccount(.fixture())
appSettingsStore.clearClipboardValues["1"] = .twoMinutes
let value = try await subject.getClearClipboardValue()
XCTAssertEqual(value, .twoMinutes)
}
/// `getConnectToWatch()` returns the connect to watch value for the active account.
func test_getConnectToWatch() async throws {
await subject.addAccount(.fixture())
@ -1836,6 +1846,15 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
}
}
/// `setArchiveOnboardingShown(_:)` sets whether the archive onboarding has been shown.
func test_setArchiveOnboardingShown() async {
await subject.setArchiveOnboardingShown(true)
XCTAssertTrue(appSettingsStore.archiveOnboardingShown)
await subject.setArchiveOnboardingShown(false)
XCTAssertFalse(appSettingsStore.archiveOnboardingShown)
}
/// `setBiometricAuthenticationEnabled(isEnabled:)` sets biometric unlock preference for the default user.
func test_setBiometricAuthenticationEnabled_default() async throws {
await subject.addAccount(.fixture())
@ -2538,6 +2557,33 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(appSettingsStore.usesKeyConnector["1"], true)
}
/// `shouldDoArchiveOnboarding()` returns `true` when active account is premium
/// and the archive onboarding has not been shown yet.
func test_shouldDoArchiveOnboarding_true() async {
await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: true)))
appSettingsStore.archiveOnboardingShown = false
let shouldDoArchiveOnboarding = await subject.shouldDoArchiveOnboarding()
XCTAssertTrue(shouldDoArchiveOnboarding)
}
/// `shouldDoArchiveOnboarding()` returns `false` when active account is premium
/// and the archive onboarding has already been shown.
func test_shouldDoArchiveOnboarding_onboardingAlreadyShown() async {
await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: true)))
appSettingsStore.archiveOnboardingShown = true
let shouldDoArchiveOnboarding = await subject.shouldDoArchiveOnboarding()
XCTAssertFalse(shouldDoArchiveOnboarding)
}
/// `shouldDoArchiveOnboarding()` returns `false` when active account is not premium
/// and the archive onboarding has not been shown yet.
func test_shouldDoArchiveOnboarding_noPremium() async {
await subject.addAccount(.fixture(profile: .fixture(hasPremiumPersonally: false)))
appSettingsStore.archiveOnboardingShown = false
let shouldDoArchiveOnboarding = await subject.shouldDoArchiveOnboarding()
XCTAssertFalse(shouldDoArchiveOnboarding)
}
/// `syncToAuthenticatorPublisher()` returns a publisher for the user's sync to authenticator settings.
func test_syncToAuthenticatorPublisher() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))

View File

@ -22,6 +22,9 @@ protocol AppSettingsStore: AnyObject {
/// The app's theme.
var appTheme: String? { get set }
/// Whether the archive onboarding has been shown.
var archiveOnboardingShown: Bool { get set }
/// The last published active user ID by `activeAccountIdPublisher` in the current process.
/// If this is different than the active user ID in the `State`, the active user was likely
/// switched in an extension and the main app should update accordingly.
@ -767,6 +770,7 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
case appLocale
case appRehydrationState(userId: String)
case appTheme
case archiveOnboardingShown
case biometricAuthEnabled(userId: String)
case clearClipboardValue(userId: String)
case connectToWatch(userId: String)
@ -840,6 +844,8 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
"appRehydrationState_\(userId)"
case .appTheme:
"theme"
case .archiveOnboardingShown:
"archiveOnboardingShown"
case let .biometricAuthEnabled(userId):
"biometricUnlock_\(userId)"
case let .clearClipboardValue(userId):
@ -955,6 +961,11 @@ extension DefaultAppSettingsStore: AppSettingsStore, ConfigSettingsStore {
set { store(newValue, for: .appTheme) }
}
var archiveOnboardingShown: Bool {
get { fetch(for: .archiveOnboardingShown) }
set { store(newValue, for: .archiveOnboardingShown) }
}
var cachedActiveUserId: String? {
activeAccountIdSubject.value
}

View File

@ -300,6 +300,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertNil(userDefaults.string(forKey: "bwPreferencesStorage:theme"))
}
/// `archiveOnboardingShown` returns `false` if there isn't a previously stored value.
func test_archiveOnboardingShown_isInitiallyFalse() {
XCTAssertFalse(subject.archiveOnboardingShown)
}
/// `archiveOnboardingShown` can be used to get and set the persisted value in user defaults.
func test_archiveOnboardingShown_withValue() {
subject.archiveOnboardingShown = true
XCTAssertTrue(subject.archiveOnboardingShown)
XCTAssertTrue(userDefaults.bool(forKey: "bwPreferencesStorage:archiveOnboardingShown"))
subject.archiveOnboardingShown = false
XCTAssertFalse(subject.archiveOnboardingShown)
XCTAssertFalse(userDefaults.bool(forKey: "bwPreferencesStorage:archiveOnboardingShown"))
}
/// `cachedActiveUserId` returns `nil` if there isn't a cached active user.
func test_cachedActiveUserId_isInitiallyNil() {
XCTAssertNil(subject.cachedActiveUserId)

View File

@ -19,6 +19,7 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
var appLocale: String?
var appRehydrationState = [String: AppRehydrationState]()
var appTheme: String?
var archiveOnboardingShown = false
var cachedActiveUserId: String?
var disableWebIcons = false
var flightRecorderData: FlightRecorderData?

View File

@ -26,6 +26,7 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
var appLanguage: LanguageOption = .default
var appRehydrationState = [String: AppRehydrationState]()
var appTheme: AppTheme?
var archiveOnboardingShown = false
var biometricsEnabled = [String: Bool]()
var capturedUserId: String?
var clearClipboardValues = [String: ClearClipboardValue]()
@ -218,6 +219,16 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
addSitePromptShown
}
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool {
let userId = try unwrapUserId(userId)
return allowSyncOnRefresh[userId] ?? false
}
func getAllowUniversalClipboard(userId: String?) async throws -> Bool {
let userId = try unwrapUserId(userId)
return allowUniversalClipboard[userId] ?? false
}
func getAppRehydrationState(userId: String?) async throws -> BitwardenShared.AppRehydrationState? {
let userId = try unwrapUserId(userId)
return appRehydrationState[userId]
@ -227,14 +238,8 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
appTheme ?? .default
}
func getAllowSyncOnRefresh(userId: String?) async throws -> Bool {
let userId = try unwrapUserId(userId)
return allowSyncOnRefresh[userId] ?? false
}
func getAllowUniversalClipboard(userId: String?) async throws -> Bool {
let userId = try unwrapUserId(userId)
return allowUniversalClipboard[userId] ?? false
func getArchiveOnboardingShown() async -> Bool {
archiveOnboardingShown
}
func getClearClipboardValue(userId: String?) async throws -> ClearClipboardValue {
@ -540,6 +545,10 @@ class MockStateService: StateService, ActiveAccountStateProvider { // swiftlint:
self.appTheme = appTheme
}
func setArchiveOnboardingShown(_ shown: Bool) async {
archiveOnboardingShown = shown
}
func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String?) async throws {
try clearClipboardResult.get()
let userId = try unwrapUserId(userId)

View File

@ -24,6 +24,9 @@ enum VaultListAction: Equatable {
/// The vault list disappeared from the screen.
case disappeared
/// The user tapped the button to go to archive.
case goToArchive
/// An item in the vault was pressed.
case itemPressed(item: VaultListItem)

View File

@ -8,6 +8,9 @@ enum VaultListEffect: Equatable {
/// Check if the user is eligible for an app review prompt.
case checkAppReviewEligibility
/// The user tapped the dismiss button on the Archive Onboarding action card.
case dismissArchiveOnboardingActionCard
/// The flight recorder toast banner was dismissed.
case dismissFlightRecorderToastBanner

View File

@ -99,6 +99,9 @@ final class VaultListProcessor: StateProcessor<
} else {
state.isEligibleForAppReview = false
}
case .dismissArchiveOnboardingActionCard:
state.shouldShowArchiveOnboardingActionCard = false
await services.stateService.setArchiveOnboardingShown(true)
case .dismissFlightRecorderToastBanner:
await dismissFlightRecorderToastBanner()
case .dismissImportLoginsActionCard:
@ -145,12 +148,20 @@ final class VaultListProcessor: StateProcessor<
coordinator.navigate(to: .addFolder)
case let .addItemPressed(type):
addItem(type: type)
case .appReviewPromptShown:
state.isEligibleForAppReview = false
Task {
await services.reviewPromptService.setReviewPromptShownVersion()
await services.reviewPromptService.clearUserActions()
}
case .clearURL:
state.url = nil
case .copyTOTPCode:
break
case .disappeared:
reviewPromptTask?.cancel()
case .goToArchive:
coordinator.navigate(to: .group(.archive, filter: state.vaultFilterType))
case let .itemPressed(item):
handleItemTapped(item)
case .navigateToFlightRecorderSettings:
@ -172,12 +183,6 @@ final class VaultListProcessor: StateProcessor<
state.searchText = newValue
case let .searchVaultFilterChanged(newValue):
state.searchVaultFilterType = newValue
case .appReviewPromptShown:
state.isEligibleForAppReview = false
Task {
await services.reviewPromptService.setReviewPromptShownVersion()
await services.reviewPromptService.clearUserActions()
}
case .showImportLogins:
coordinator.navigate(to: .importLogins)
case let .toastShown(newValue):
@ -216,6 +221,7 @@ extension VaultListProcessor {
await checkPendingLoginRequests()
await checkPersonalOwnershipPolicy()
await loadItemTypesUserCanCreate()
state.shouldShowArchiveOnboardingActionCard = await services.stateService.shouldDoArchiveOnboarding()
}
/// Checks if the user needs to update their KDF settings.

View File

@ -537,6 +537,30 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertEqual(stateService.notificationsLastRegistrationDates["1"], timeProvider.presentTime)
}
/// `perform(_:)` with `.appeared` updates whether to show the archive onboarding card.
@MainActor
func test_perform_appeared_loadArchiveOnboarding() async {
stateService.doesActiveAccountHavePremiumResult = true
stateService.archiveOnboardingShown = false
await subject.perform(.appeared)
XCTAssertTrue(subject.state.shouldShowArchiveOnboardingActionCard)
}
/// `perform(_:)` with `.dismissArchiveOnboardingActionCard` dismisses the archive onboarding card
/// and sets the archive onboarding shown property to true.
@MainActor
func test_perform_dismissArchiveOnboardingActionCard() async {
subject.state.shouldShowArchiveOnboardingActionCard = true
XCTAssertFalse(stateService.archiveOnboardingShown)
await subject.perform(.dismissArchiveOnboardingActionCard)
XCTAssertFalse(subject.state.shouldShowArchiveOnboardingActionCard)
XCTAssertTrue(stateService.archiveOnboardingShown)
}
/// `perform(_:)` with `.dismissFlightRecorderToastBanner` hides the flight recorder toast banner.
@MainActor
func test_perform_dismissFlightRecorderToastBanner() async {
@ -1815,6 +1839,13 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertTrue(subject.reviewPromptTask!.isCancelled)
}
/// `receive(_:)` with `.goToArchive` navigates to archive group.
@MainActor
func test_receive_goToArchive() {
subject.receive(.goToArchive)
XCTAssertEqual(coordinator.routes.last, .group(.archive, filter: .allVaults))
}
/// `receive(_:)` with `.itemPressed` navigates to the `.viewItem` route for a cipher.
@MainActor
func test_receive_itemPressed_cipher() async throws {

View File

@ -81,6 +81,9 @@ struct VaultListState: Equatable {
)
}
/// Whether the Archive Onboarding action card should be shown.
var shouldShowArchiveOnboardingActionCard: Bool = false
/// Whether the import logins action card should be shown.
var shouldShowImportLoginsActionCard: Bool {
importLoginsSetupProgress == .incomplete

View File

@ -13,6 +13,56 @@ import XCTest
// MARK: - VaultListViewTests
class VaultListViewTests: BitwardenTestCase {
// MARK: Static properties
/// An array of vault list sections with default data to fill the vault.
static var defaultVaultData: [VaultListSection] {
[
VaultListSection(
id: "",
items: [
.fixture(cipherListView: .fixture(
login: .fixture(username: "email@example.com"),
name: "Example",
subtitle: "email@example.com",
)),
.fixture(cipherListView: .fixture(id: "12", name: "Example", type: .secureNote)),
.fixture(cipherListView: .fixture(
id: "13",
organizationId: "1",
login: .fixture(username: "user@bitwarden.com"),
name: "Bitwarden",
subtitle: "user@bitwarden.com",
attachments: 1,
)),
],
name: "Favorites",
),
VaultListSection(
id: "2",
items: [
VaultListItem(
id: "21",
itemType: .group(.login, 123),
),
VaultListItem(
id: "22",
itemType: .group(.card, 25),
),
VaultListItem(
id: "23",
itemType: .group(.identity, 1),
),
VaultListItem(
id: "24",
itemType: .group(.secureNote, 0),
),
],
name: "Types",
),
]
}
// MARK: Properties
var processor: MockProcessor<VaultListState, VaultListAction, VaultListEffect>!
@ -96,50 +146,17 @@ class VaultListViewTests: BitwardenTestCase {
@MainActor
func disabletest_snapshot_myVault() {
processor.state.loadingState = .data([
VaultListSection(
id: "",
items: [
.fixture(cipherListView: .fixture(
login: .fixture(username: "email@example.com"),
name: "Example",
subtitle: "email@example.com",
)),
.fixture(cipherListView: .fixture(id: "12", name: "Example", type: .secureNote)),
.fixture(cipherListView: .fixture(
id: "13",
organizationId: "1",
login: .fixture(username: "user@bitwarden.com"),
name: "Bitwarden",
subtitle: "user@bitwarden.com",
attachments: 1,
)),
],
name: "Favorites",
),
VaultListSection(
id: "2",
items: [
VaultListItem(
id: "21",
itemType: .group(.login, 123),
),
VaultListItem(
id: "22",
itemType: .group(.card, 25),
),
VaultListItem(
id: "23",
itemType: .group(.identity, 1),
),
VaultListItem(
id: "24",
itemType: .group(.secureNote, 0),
),
],
name: "Types",
),
])
processor.state.loadingState = .data(VaultListViewTests.defaultVaultData)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],
)
}
@MainActor
func disabletest_snapshot_myVaultArchiveOnboarding() {
processor.state.shouldShowArchiveOnboardingActionCard = true
processor.state.loadingState = .data(VaultListViewTests.defaultVaultData)
assertSnapshots(
of: subject,
as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5],

View File

@ -75,6 +75,24 @@ private struct SearchableVaultListView: View {
// MARK: Private Properties
/// The action card for importing login items.
@ViewBuilder private var archiveOnboardingActionCard: some View {
if store.state.shouldShowArchiveOnboardingActionCard {
ActionCard(
title: Localizations.introducingArchive,
message: Localizations.keepYtemsYouDontNeedRightNowSafeButOutOfSight,
actionButtonState: ActionCard.ButtonState(title: Localizations.goToArchive) {
store.send(.goToArchive)
},
dismissButtonState: ActionCard.ButtonState(title: Localizations.dismiss) {
await store.perform(.dismissArchiveOnboardingActionCard)
},
) {
SharedAsset.Icons.archive24.swiftUIImage.foregroundStyle(SharedAsset.Colors.iconSecondary.swiftUIColor)
}
}
}
/// A view that displays the empty vault interface.
@ViewBuilder private var emptyVault: some View {
VStack(spacing: 24) {
@ -250,6 +268,8 @@ private struct SearchableVaultListView: View {
@ViewBuilder
private func vaultContents(with sections: [VaultListSection]) -> some View {
VStack(spacing: 20) {
archiveOnboardingActionCard
vaultFilterRow
ForEach(sections) { section in