Skip to content

Commit

Permalink
BITAU-129: Persist closing cards (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
victor-livefront authored Sep 16, 2024
1 parent cd4432d commit 7bd1e9e
Show file tree
Hide file tree
Showing 10 changed files with 330 additions and 9 deletions.
9 changes: 8 additions & 1 deletion AuthenticatorShared/Core/Platform/Services/Services.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import BitwardenSdk

/// The services provided by the `ServiceContainer`.
typealias Services = HasApplication
typealias Services = HasAppSettingsStore
& HasApplication
& HasAuthenticatorItemRepository
& HasBiometricsRepository
& HasCameraService
Expand All @@ -22,6 +23,12 @@ protocol HasApplication {
var application: Application? { get }
}

/// Protocol for an object that provides an AppSettingsStore.
///
protocol HasAppSettingsStore {
var appSettingsStore: AppSettingsStore { get }
}

/// Protocol for an object that provides an `AuthenticatorItemRepository`
///
protocol HasAuthenticatorItemRepository {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ protocol AppSettingsStore: AnyObject {
///
func biometricIntegrityState(userId: String) -> String?

/// Gets the closed state for the given card.
///
/// - Parameter card: The card to get the closed state for.
///
/// - Returns: Whether or not this card has been closed.
///
func cardClosedState(card: ItemListCard) -> Bool

/// Gets the time after which the clipboard should be cleared.
///
/// - Parameter userId: The user ID associated with the clipboard clearing time.
Expand Down Expand Up @@ -78,6 +86,12 @@ protocol AppSettingsStore: AnyObject {
///
func setBiometricIntegrityState(_ base64EncodedIntegrityState: String?, userId: String)

/// Sets the closed state to true for the given card.
///
/// - Parameter card: The card to set the closed state for.
///
func setCardClosedState(card: ItemListCard)

/// Sets the time after which the clipboard should be cleared.
///
/// - Parameters:
Expand Down Expand Up @@ -216,6 +230,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case appTheme
case biometricAuthEnabled(userId: String)
case biometricIntegrityState(userId: String, bundleId: String)
case cardClosedState(card: ItemListCard)
case clearClipboardValue(userId: String)
case disableWebIcons
case hasSeenWelcomeTutorial
Expand All @@ -236,6 +251,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "biometricUnlock_\(userId)"
case let .biometricIntegrityState(userId, bundleId):
key = "biometricIntegritySource_\(userId)_\(bundleId)"
case let .cardClosedState(card: card):
key = "cardClosedState_\(card)"
case let .clearClipboardValue(userId):
key = "clearClipboard_\(userId)"
case .disableWebIcons:
Expand Down Expand Up @@ -290,6 +307,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
)
}

func cardClosedState(card: ItemListCard) -> Bool {
fetch(for: .cardClosedState(card: card))
}

func clearClipboardValue(userId: String) -> ClearClipboardValue {
if let rawValue: Int = fetch(for: .clearClipboardValue(userId: userId)),
let value = ClearClipboardValue(rawValue: rawValue) {
Expand Down Expand Up @@ -320,6 +341,10 @@ extension DefaultAppSettingsStore: AppSettingsStore {
)
}

func setCardClosedState(card: ItemListCard) {
store(true, for: .cardClosedState(card: card))
}

func setClearClipboardValue(_ clearClipboardValue: ClearClipboardValue?, userId: String) {
store(clearClipboardValue?.rawValue, for: .clearClipboardValue(userId: userId))
}
Expand All @@ -328,3 +353,13 @@ extension DefaultAppSettingsStore: AppSettingsStore {
store(key, for: .secretKey(userId: userId))
}
}

/// An enumeration of possible item list cards.
///
enum ItemListCard: String {
/// The password manager download card.
case passwordManagerDownload

/// The password manager sync card.
case passwordManagerSync
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import XCTest

@testable import AuthenticatorShared

// MARK: - AppSettingsStoreTests

class AppSettingsStoreTests: AuthenticatorTestCase {
// MARK: Properties

var subject: AppSettingsStore!
var userDefaults: UserDefaults!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()

userDefaults = UserDefaults(suiteName: "AppSettingsStoreTests")

userDefaults.dictionaryRepresentation()
.keys
.filter { $0.hasPrefix("bwaPreferencesStorage:") }
.forEach { key in
userDefaults.removeObject(forKey: key)
}

subject = DefaultAppSettingsStore(userDefaults: userDefaults)
}

override func tearDown() {
super.tearDown()

subject = nil
userDefaults = nil
}

// MARK: Tests

/// `appId` returns `nil` if there isn't a previously stored value.
func test_appId_isInitiallyNil() {
XCTAssertNil(subject.appId)
}

/// `appId` can be used to get and set the persisted value in user defaults.
func test_appId_withValue() {
subject.appId = "📱"
XCTAssertEqual(subject.appId, "📱")
XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:appId"), "📱")

subject.appId = "☎️"
XCTAssertEqual(subject.appId, "☎️")
XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:appId"), "☎️")

subject.appId = nil
XCTAssertNil(subject.appId)
XCTAssertNil(userDefaults.string(forKey: "bwaPreferencesStorage:appId"))
}

/// `appLocale`is initially `nil`.
func test_appLocale_isInitiallyNil() {
XCTAssertNil(subject.appLocale)
}

/// `appLocale` can be used to get and set the persisted value in user defaults.
func test_appLocale_withValue() {
subject.appLocale = "th"
XCTAssertEqual(subject.appLocale, "th")
XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:appLocale"), "th")

subject.appLocale = nil
XCTAssertNil(subject.appLocale)
XCTAssertNil(userDefaults.string(forKey: "bwaPreferencesStorage:appLocale"))
}

/// `appTheme` returns `nil` if there isn't a previously stored value.
func test_appTheme_isInitiallyNil() {
XCTAssertNil(subject.appTheme)
}

/// `appTheme` can be used to get and set the persisted value in user defaults.
func test_appTheme_withValue() {
subject.appTheme = "light"
XCTAssertEqual(subject.appTheme, "light")
XCTAssertEqual(userDefaults.string(forKey: "bwaPreferencesStorage:theme"), "light")

subject.appTheme = nil
XCTAssertNil(subject.appTheme)
XCTAssertNil(userDefaults.string(forKey: "bwaPreferencesStorage:theme"))
}

/// `biometricIntegrityState` returns nil if there is no previous value.
func test_biometricIntegrityState_isInitiallyNil() {
XCTAssertNil(subject.biometricIntegrityState(userId: "-1"))
}

/// `biometricIntegrityState` can be used to get and set the persisted value in user defaults.
func test_biometricIntegrityState_withValue() {
subject.setBiometricIntegrityState("state1", userId: "0")
subject.setBiometricIntegrityState("state2", userId: "1")

XCTAssertEqual("state1", subject.biometricIntegrityState(userId: "0"))
XCTAssertEqual("state2", subject.biometricIntegrityState(userId: "1"))

subject.setBiometricIntegrityState("state3", userId: "0")
subject.setBiometricIntegrityState("state4", userId: "1")

XCTAssertEqual("state3", subject.biometricIntegrityState(userId: "0"))
XCTAssertEqual("state4", subject.biometricIntegrityState(userId: "1"))
}

/// `cardClosedState` returns `false` if there isn't a previously stored value.
func test_cardClosedState_isInitiallyFalse() {
XCTAssertFalse(subject.cardClosedState(card: .passwordManagerDownload))
XCTAssertFalse(subject.cardClosedState(card: .passwordManagerSync))
}

/// `cardClosedState` can be used to get and set the persisted value in user defaults.
func test_cardClosedState_withValue() {
subject.setCardClosedState(card: .passwordManagerDownload)
XCTAssertTrue(subject.cardClosedState(card: .passwordManagerDownload))
XCTAssertTrue(userDefaults.bool(forKey: "bwaPreferencesStorage:cardClosedState_passwordManagerDownload"))

subject.setCardClosedState(card: .passwordManagerSync)
XCTAssertTrue(subject.cardClosedState(card: .passwordManagerSync))
XCTAssertTrue(userDefaults.bool(forKey: "bwaPreferencesStorage:cardClosedState_passwordManagerSync"))
}

/// `clearClipboardValue(userId:)` returns `.never` if there isn't a previously stored value.
func test_clearClipboardValue_isInitiallyNever() {
XCTAssertEqual(subject.clearClipboardValue(userId: "0"), .never)
}

/// `clearClipboardValue(userId:)` can be used to get the clear clipboard value for a user.
func test_clearClipboardValue_withValue() {
subject.setClearClipboardValue(.tenSeconds, userId: "1")
subject.setClearClipboardValue(.never, userId: "2")

XCTAssertEqual(subject.clearClipboardValue(userId: "1"), .tenSeconds)
XCTAssertEqual(subject.clearClipboardValue(userId: "2"), .never)
XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:clearClipboard_1"), 10)
XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:clearClipboard_2"), -1)
}

/// `disableWebIcons` returns `false` if there isn't a previously stored value.
func test_disableWebIcons_isInitiallyFalse() {
XCTAssertFalse(subject.disableWebIcons)
}

/// `disableWebIcons` can be used to get and set the persisted value in user defaults.
func test_disableWebIcons_withValue() {
subject.disableWebIcons = true
XCTAssertTrue(subject.disableWebIcons)
XCTAssertTrue(userDefaults.bool(forKey: "bwaPreferencesStorage:disableFavicon"))

subject.disableWebIcons = false
XCTAssertFalse(subject.disableWebIcons)
XCTAssertFalse(userDefaults.bool(forKey: "bwaPreferencesStorage:disableFavicon"))
}

/// `isBiometricAuthenticationEnabled` returns false if there is no previous value.
func test_isBiometricAuthenticationEnabled_isInitiallyFalse() {
XCTAssertFalse(subject.isBiometricAuthenticationEnabled(userId: "-1"))
}

/// `isBiometricAuthenticationEnabled` can be used to get the biometric unlock preference for a user.
func test_isBiometricAuthenticationEnabled_withValue() {
subject.setBiometricAuthenticationEnabled(false, for: "0")
subject.setBiometricAuthenticationEnabled(true, for: "1")

XCTAssertFalse(subject.isBiometricAuthenticationEnabled(userId: "0"))
XCTAssertTrue(subject.isBiometricAuthenticationEnabled(userId: "1"))

subject.setBiometricAuthenticationEnabled(true, for: "0")
subject.setBiometricAuthenticationEnabled(false, for: "1")

XCTAssertTrue(subject.isBiometricAuthenticationEnabled(userId: "0"))
XCTAssertFalse(subject.isBiometricAuthenticationEnabled(userId: "1"))
}

/// `migrationVersion` returns `0` if there isn't a previously stored value.
func test_migrationVersion_isInitiallyZero() {
XCTAssertEqual(subject.migrationVersion, 0)
}

/// `migrationVersion` can be used to get and set the migration version.
func test_migrationVersion_withValue() throws {
subject.migrationVersion = 1
XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:migrationVersion"), 1)
XCTAssertEqual(subject.migrationVersion, 1)

subject.migrationVersion = 2
XCTAssertEqual(userDefaults.integer(forKey: "bwaPreferencesStorage:migrationVersion"), 2)
XCTAssertEqual(subject.migrationVersion, 2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class MockAppSettingsStore: AppSettingsStore {
var approveLoginRequestsByUserId = [String: Bool]()
var biometricAuthenticationEnabled = [String: Bool?]()
var biometricIntegrityStates = [String: String?]()
var cardClosedStateValues = [ItemListCard: Bool]()
var clearClipboardValues = [String: ClearClipboardValue]()
var connectToWatchByUserId = [String: Bool]()
var disableAutoTotpCopyByUserId = [String: Bool]()
Expand All @@ -38,6 +39,14 @@ class MockAppSettingsStore: AppSettingsStore {

var unsuccessfulUnlockAttempts = [String: Int]()

func cardClosedState(card: ItemListCard) -> Bool {
cardClosedStateValues[card] ?? false
}

func setCardClosedState(card: ItemListCard) {
cardClosedStateValues[card] = true
}

func clearClipboardValue(userId: String) -> ClearClipboardValue {
clearClipboardValues[userId] ?? .never
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import XCTest
@testable import AuthenticatorShared

@MainActor
class StoreTests: XCTestCase {
class StoreTests: AuthenticatorTestCase {
var processor: MockProcessor<TestState, TestAction, TestEffect>!
var subject: Store<TestState, TestAction, TestEffect>!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ enum ItemListEffect: Equatable {
/// The vault group view appeared on screen.
case appeared

/// The close button was pressed on the given card.
case closeCard(ItemListCard)

/// The copy code button was pressed.
///
case copyPressed(_ item: ItemListItem)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import Foundation
final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, ItemListEffect> {
// MARK: Types

typealias Services = HasApplication
typealias Services = HasAppSettingsStore
& HasApplication
& HasAuthenticatorItemRepository
& HasCameraService
& HasConfigService
Expand Down Expand Up @@ -66,6 +67,9 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
case .appeared:
await determineItemListCardState()
await streamItemList()
case let .closeCard(card):
services.appSettingsStore.setCardClosedState(card: card)
await determineItemListCardState()
case let .copyPressed(item):
switch item.itemType {
case let .totp(model):
Expand Down Expand Up @@ -260,16 +264,23 @@ final class ItemListProcessor: StateProcessor<ItemListState, ItemListAction, Ite
/// Determine if the ItemListCard should be shown and which state to show.
///
private func determineItemListCardState() async {
guard await services.configService.getFeatureFlag(.enablePasswordManagerSync) else {
guard await services.configService.getFeatureFlag(.enablePasswordManagerSync),
let application = services.application else {
state.itemListCardState = .none
return
}

guard services.application?.canOpenURL(ExternalLinksConstants.passwordManagerScheme) == true else {
let passwordManagerInstalled = application.canOpenURL(ExternalLinksConstants.passwordManagerScheme)
let hasClosedDownloadCard = services.appSettingsStore.cardClosedState(card: .passwordManagerDownload)
let hasClosedSyncCard = services.appSettingsStore.cardClosedState(card: .passwordManagerSync)

if !passwordManagerInstalled, !hasClosedDownloadCard {
state.itemListCardState = .passwordManagerDownload
return
} else if passwordManagerInstalled, !hasClosedSyncCard {
state.itemListCardState = .passwordManagerSync
} else {
state.itemListCardState = .none
}
state.itemListCardState = .passwordManagerSync
}
}

Expand Down
Loading

0 comments on commit 7bd1e9e

Please sign in to comment.