Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PM-16141: Learn Generator action card #1273

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
19 changes: 19 additions & 0 deletions BitwardenShared/Core/Platform/Services/StateService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,12 @@ protocol StateService: AnyObject {
///
func getLastUserShouldConnectToWatch() async -> Bool

/// Gets the status of Learn Generator Action Card.
///
/// - Returns: The status of Learn generator Action Card.
///
func getLearnGeneratorActionCardStatus() async -> AccountSetupProgress?

/// Get any pending login request data.
///
/// - Returns: The pending login request data from a push notification.
Expand Down Expand Up @@ -519,6 +525,11 @@ protocol StateService: AnyObject {
///
func setIntroCarouselShown(_ shown: Bool) async

/// Sets the status of Learn generator Action Card.
///
/// - Parameter status: The status of Learn generator Action Card.
///
func setLearnGeneratorActionCardStatus(_ status: AccountSetupProgress) async
/// Sets the status of Learn New Login Action Card.
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
///
/// - Parameter status: The status of Learn New Login Action Card.
Expand Down Expand Up @@ -1555,6 +1566,10 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
appSettingsStore.accountCreationEnvironmentURLs(email: email)
}

func getLearnGeneratorActionCardStatus() async -> AccountSetupProgress? {
appSettingsStore.learnGeneratorActionCardStatus
}

func getPreAuthServerConfig() async -> ServerConfig? {
appSettingsStore.preAuthServerConfig
}
Expand Down Expand Up @@ -1800,6 +1815,10 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
lastSyncTimeByUserIdSubject.value[userId] = date
}

func setLearnGeneratorActionCardStatus(_ status: AccountSetupProgress) async {
appSettingsStore.learnGeneratorActionCardStatus = status
ezimet-livefront marked this conversation as resolved.
Show resolved Hide resolved
}

func setLoginRequest(_ loginRequest: LoginRequestNotification?) async {
appSettingsStore.loginRequest = loginRequest
}
Expand Down
19 changes: 19 additions & 0 deletions BitwardenShared/Core/Platform/Services/StateServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,16 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
XCTAssertEqual(lastSyncTime, date)
}

/// `getLearnGeneratorActionCardStatus()` returns the status of the learn generator action card.
func test_getLearnGeneratorActionCardStatus() async {
var learnGeneratorActionCardStatus = await subject.getLearnGeneratorActionCardStatus()
XCTAssertEqual(learnGeneratorActionCardStatus, .incomplete)

appSettingsStore.learnGeneratorActionCardStatus = .complete
learnGeneratorActionCardStatus = await subject.getLearnGeneratorActionCardStatus()
XCTAssertEqual(learnGeneratorActionCardStatus, .complete)
}

/// `getLoginRequest()` gets any pending login requests.
func test_getLoginRequest() async {
let loginRequest = LoginRequestNotification(id: "1", userId: "10")
Expand Down Expand Up @@ -1772,6 +1782,15 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
)
}

/// `setLearnGeneratorActionCardStatus(_:)` sets the learn generator action card status.
func test_setLearnGeneratorActionCardStatus() async {
await subject.setLearnGeneratorActionCardStatus(.incomplete)
XCTAssertEqual(appSettingsStore.learnGeneratorActionCardStatus, .incomplete)

await subject.setLearnGeneratorActionCardStatus(.complete)
XCTAssertEqual(appSettingsStore.learnGeneratorActionCardStatus, .complete)
}

/// `setLearnNewLoginActionCardStatus(_:)` sets the learn new login action card status.
func test_setLearnNewLoginActionCardStatus() async {
await subject.setLearnNewLoginActionCardStatus(.incomplete)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ protocol AppSettingsStore: AnyObject {
/// sending the status to the watch if the user is logged out.
var lastUserShouldConnectToWatch: Bool { get set }

/// The status of the learn generator action card.
var learnGeneratorActionCardStatus: AccountSetupProgress { get set }

/// The status of the learn new login action card.
var learnNewLoginActionCardStatus: AccountSetupProgress { get set }

Expand Down Expand Up @@ -716,6 +719,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case lastActiveTime(userId: String)
case lastSync(userId: String)
case lastUserShouldConnectToWatch
case learnGeneratorActionCardStatus
case loginRequest
case manuallyLockedAccount(userId: String)
case masterPasswordHash(userId: String)
Expand Down Expand Up @@ -793,6 +797,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "lastActiveTime_\(userId)"
case let .lastSync(userId):
key = "lastSync_\(userId)"
case .learnGeneratorActionCardStatus:
key = "learnGeneratorActionCardStatus"
case .lastUserShouldConnectToWatch:
key = "lastUserShouldConnectToWatch"
case .loginRequest:
Expand Down Expand Up @@ -892,6 +898,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
set { store(newValue, for: .lastUserShouldConnectToWatch) }
}

var learnGeneratorActionCardStatus: AccountSetupProgress {
get { fetch(for: .learnGeneratorActionCardStatus) ?? .incomplete }
set { store(newValue, for: .learnGeneratorActionCardStatus) }
}

var loginRequest: LoginRequestNotification? {
get { fetch(for: .loginRequest) }
set { store(newValue, for: .loginRequest) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,29 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
XCTAssertEqual(userDefaults.double(forKey: "bwPreferencesStorage:lastSync_2"), 1_685_664_000.0)
}

/// `learnGeneratorActionCardStatus` returns `.incomplete` if there isn't a previously stored value.
func test_learnGeneratorActionCardStatus_isInitiallyIncomplete() {
XCTAssertEqual(subject.learnGeneratorActionCardStatus, .incomplete)
}

/// `learnGeneratorActionCardStatus` can be used to get and set the persisted value in user defaults.
func test_learnGeneratorActionCardStatus_withValues() {
subject.learnGeneratorActionCardStatus = .complete
XCTAssertEqual(subject.learnGeneratorActionCardStatus, .complete)

try XCTAssertEqual(
JSONDecoder().decode(
AccountSetupProgress.self,
from: XCTUnwrap(
userDefaults
.string(forKey: "bwPreferencesStorage:learnGeneratorActionCardStatus")?
.data(using: .utf8)
)
),
AccountSetupProgress.complete
)
}

/// `loginRequest` returns `nil` if there isn't a previously stored value.
func test_loginRequest_isInitiallyNil() {
XCTAssertNil(subject.loginRequest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class MockAppSettingsStore: AppSettingsStore { // swiftlint:disable:this type_bo
var disableWebIcons = false
var introCarouselShown = false
var lastUserShouldConnectToWatch = false
var learnGeneratorActionCardStatus: AccountSetupProgress = .incomplete
var learnNewLoginActionCardStatus: AccountSetupProgress = .incomplete
var loginRequest: LoginRequestNotification?
var migrationVersion = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var isAuthenticated = [String: Bool]()
var isAuthenticatedError: Error?
var lastActiveTime = [String: Date]()
var learnGeneratorActionCardStatus: AccountSetupProgress?
var learnNewLoginActionCardStatus: AccountSetupProgress?
var loginRequest: LoginRequestNotification?
var logoutAccountUserInitiated = false
Expand Down Expand Up @@ -265,6 +266,10 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
return lastSyncTimeByUserId[userId]
}

func getLearnGeneratorActionCardStatus() async -> AccountSetupProgress? {
learnGeneratorActionCardStatus
}

func getLoginRequest() async -> LoginRequestNotification? {
loginRequest
}
Expand Down Expand Up @@ -528,6 +533,10 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
lastUserShouldConnectToWatch
}

func setLearnGeneratorActionCardStatus(_ status: AccountSetupProgress) async {
learnGeneratorActionCardStatus = status
}

func setLoginRequest(_ loginRequest: LoginRequestNotification?) async {
self.loginRequest = loginRequest
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -934,6 +934,8 @@
"UnknownAccount" = "Unknown account";
"UserVerificationForPasskey" = "User verification for passkey";
"NewItem" = "New item";
"ExploreTheGenerator" = "Explore the generator";
"LearnMoreAboutGeneratingSecureLoginCredentialsWithAGuidedTour" = "Learn more about generating secure login credentials with a guided tour.";
"ExportVaultFilePwProtectInfo" = "This file export will be password protected and require the file password to decrypt.";
"IntroCarouselPage1Title" = "Security, prioritized";
"IntroCarouselPage1Message" = "Save logins, cards, and identities to your secure vault. Bitwarden uses zero-knowledge, end-to-end encryption to protect whatโ€™s important to you.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,10 @@
enum GeneratorEffect {
/// The generator view appeared on screen.
case appeared

/// The user tapped the dismiss button on the learn generator action card.
case dismissLearnGeneratorActionCard

/// Show the learn generator guided tour.
case showLearnGeneratorGuidedTour
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import OSLog
final class GeneratorProcessor: StateProcessor<GeneratorState, GeneratorAction, GeneratorEffect> {
// MARK: Types

typealias Services = HasErrorReporter
typealias Services = HasConfigService
& HasErrorReporter
& HasGeneratorRepository
& HasPasteboardService
& HasPolicyService
& HasReviewPromptService
& HasStateService

/// The behavior that should be taken after receiving a new action for generating a new value
/// and persisting it.
Expand Down Expand Up @@ -78,6 +80,14 @@ final class GeneratorProcessor: StateProcessor<GeneratorState, GeneratorAction,
switch effect {
case .appeared:
await generateValue(shouldSavePassword: true)
await checkLearnGeneratorActionCardEligibility()
case .dismissLearnGeneratorActionCard:
await services.stateService.setLearnGeneratorActionCardStatus(.complete)
state.isLearnGeneratorActionCardEligible = false
case .showLearnGeneratorGuidedTour:
state.generatorType = .password
await services.stateService.setLearnGeneratorActionCardStatus(.complete)
state.isLearnGeneratorActionCardEligible = false
}
}

Expand Down Expand Up @@ -175,6 +185,15 @@ final class GeneratorProcessor: StateProcessor<GeneratorState, GeneratorAction,

// MARK: Private

/// Checks the eligibility of the generator Login action card.
///
private func checkLearnGeneratorActionCardEligibility() async {
if await services.configService.getFeatureFlag(.nativeCreateAccountFlow) {
state.isLearnGeneratorActionCardEligible = await services.stateService
.getLearnGeneratorActionCardStatus() == .incomplete
}
}

/// Generate a new passphrase.
///
/// - Parameter settings: The passphrase generator settings used to generate a new password.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,44 @@ import XCTest
class GeneratorProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties

var configService: MockConfigService!
var coordinator: MockCoordinator<GeneratorRoute, Void>!
var errorReporter: MockErrorReporter!
var generatorRepository: MockGeneratorRepository!
var pasteboardService: MockPasteboardService!
var policyService: MockPolicyService!
var reviewPromptService: MockReviewPromptService!
var stateService: MockStateService!
var subject: GeneratorProcessor!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()

configService = MockConfigService()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
generatorRepository = MockGeneratorRepository()
pasteboardService = MockPasteboardService()
policyService = MockPolicyService()
reviewPromptService = MockReviewPromptService()
stateService = MockStateService()

setUpSubject()
}

override func tearDown() {
super.tearDown()

configService = nil
coordinator = nil
errorReporter = nil
generatorRepository = nil
pasteboardService = nil
policyService = nil
reviewPromptService = nil
stateService = nil
subject = nil
}

Expand All @@ -48,11 +54,13 @@ class GeneratorProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
subject = GeneratorProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
configService: configService,
errorReporter: errorReporter,
generatorRepository: generatorRepository,
pasteboardService: pasteboardService,
policyService: policyService,
reviewPromptService: reviewPromptService
reviewPromptService: reviewPromptService,
stateService: stateService
),
state: GeneratorState()
)
Expand Down Expand Up @@ -406,6 +414,36 @@ class GeneratorProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertEqual(generatorRepository.passwordGeneratorRequest?.length, 50)
}

/// `perform(:)` with `.appeared` should set the `isLearnGeneratorActionCardEligible` to `true`
/// if the `learnGeneratorActionCardStatus` is `incomplete`, and feature flag is enabled.
@MainActor
func test_perform_checkLearnNewLoginActionCardEligibility() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = true
stateService.learnGeneratorActionCardStatus = .incomplete
await subject.perform(.appeared)
XCTAssertTrue(subject.state.isLearnGeneratorActionCardEligible)
}

/// `perform(:)` with `.appeared` should not set the `isLearnNewLoginActionCardEligible` to `true`
/// if the feature flag `nativeCreateAccountFlow` is `false`.
@MainActor
func test_perform_checkLearnNewLoginActionCardEligibility_false() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = false
stateService.learnGeneratorActionCardStatus = .incomplete
await subject.perform(.appeared)
XCTAssertFalse(subject.state.isLearnGeneratorActionCardEligible)
}

/// `perform(:)` with `.appeared` should not set the `isLearnNewLoginActionCardEligible` to `true`
/// if the `learnGeneratorActionCardStatus` is `complete`.
@MainActor
func test_perform_checkLearnNewLoginActionCardEligibility_false_complete() async {
configService.featureFlagsBool[.nativeCreateAccountFlow] = true
stateService.learnGeneratorActionCardStatus = .complete
await subject.perform(.appeared)
XCTAssertFalse(subject.state.isLearnGeneratorActionCardEligible)
}

/// `receive(_:)` with `.copyGeneratedValร˜ue` copies the generated password to the system
/// pasteboard and shows a toast.
@MainActor
Expand Down Expand Up @@ -458,6 +496,28 @@ class GeneratorProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertEqual(coordinator.routes.last, .cancel)
}

/// `perform(_:)` with `.dismissNewLoginActionCard` will set `.isLearnGeneratorActionCardEligible` to
/// false and updates `.learnGeneratorActionCardStatus` via stateService.
@MainActor
func test_perform_dismissLearnGeneratorActionCard() async {
subject.state.isLearnGeneratorActionCardEligible = true
await subject.perform(.dismissLearnGeneratorActionCard)
XCTAssertFalse(subject.state.isLearnGeneratorActionCardEligible)
XCTAssertEqual(stateService.learnGeneratorActionCardStatus, .complete)
}

/// `perform(_:)` with `.showLearnGeneratorGuidedTour` sets `isLearnGeneratorActionCardEligible`
/// to `false`.
@MainActor
func test_perform_showLearnNewLoginGuidedTour() async {
subject.state.isLearnGeneratorActionCardEligible = true
subject.state.generatorType = .username
await subject.perform(.showLearnGeneratorGuidedTour)
XCTAssertFalse(subject.state.isLearnGeneratorActionCardEligible)
XCTAssertEqual(stateService.learnGeneratorActionCardStatus, .complete)
XCTAssertEqual(subject.state.generatorType, .password)
}

/// `receive(_:)` with `.emailTypeChanged` updates the state's catch all email type.
@MainActor
func test_receive_emailTypeChanged_catchAll() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ struct GeneratorState: Equatable {
/// The generated value (password, passphrase or username).
var generatedValue: String = ""

/// If account is eligible for learn generator action card.
var isLearnGeneratorActionCardEligible: Bool = false

/// Whether there's a password generation policy in effect.
var isPolicyInEffect = false

Expand Down
Loading
Loading