diff --git a/Bitwarden/Application/SceneDelegate.swift b/Bitwarden/Application/SceneDelegate.swift index 4958c7a28..8d9b5f7ea 100644 --- a/Bitwarden/Application/SceneDelegate.swift +++ b/Bitwarden/Application/SceneDelegate.swift @@ -72,6 +72,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let incomingURL = userActivity.webpageURL { appProcessor.handleAppLinks(incomingURL: incomingURL) } + + #if compiler(>=6.0.3) + + if #available(iOS 18.2, *), + let userActivity = connectionOptions.userActivities.first { + await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity) + } + + #endif } } @@ -90,13 +99,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { #if compiler(>=6.0.3) - if #available(iOS 18.2, *), - userActivity.activityType == ASCredentialExchangeActivity { - guard let token = userActivity.userInfo?[ASCredentialImportToken] as? UUID else { - return + if #available(iOS 18.2, *) { + Task { + await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity) } - - appProcessor.handleImportCredentials(credentialImportToken: token) } #endif @@ -173,3 +179,28 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } #endif } + +// MARK: - SceneDelegate 18.2 + +#if compiler(>=6.0.3) + +@available(iOS 18.2, *) +extension SceneDelegate { + /// Checks whether there is an `ASCredentialExchangeActivity` in the `userActivity` and handles it. + /// - Parameters: + /// - appProcessor: The `AppProcessor` to handle the logic. + /// - userActivity: The activity to handle. + private func checkAndHandleCredentialExchangeActivity( + appProcessor: AppProcessor, + userActivity: NSUserActivity + ) async { + guard userActivity.activityType == ASCredentialExchangeActivity, + let token = userActivity.userInfo?[ASCredentialImportToken] as? UUID else { + return + } + + await appProcessor.handleImportCredentials(credentialImportToken: token) + } +} + +#endif diff --git a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift index 41e8d5d9a..5b5bd3e9f 100644 --- a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift +++ b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoder+Bitwarden.swift @@ -1,25 +1,6 @@ import Foundation extension JSONDecoder { - // MARK: Types - - /// `AnyKey` is a `CodingKey` type that can be used for encoding and decoding keys for custom - /// key decoding strategies. - struct AnyKey: CodingKey { - let stringValue: String - let intValue: Int? - - init(stringValue: String) { - self.stringValue = stringValue - intValue = nil - } - - init(intValue: Int) { - stringValue = String(intValue) - self.intValue = intValue - } - } - // MARK: Static Properties /// The default `JSONDecoder` used to decode JSON payloads throughout the app. diff --git a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoderBitwardenTests.swift b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoderBitwardenTests.swift index ddae6ff89..6537d4ec7 100644 --- a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoderBitwardenTests.swift +++ b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONDecoderBitwardenTests.swift @@ -5,6 +5,32 @@ import XCTest class JSONDecoderBitwardenTests: BitwardenTestCase { // MARK: Tests + /// `JSONDecoder.cxpDecoder` can decode Credential Exchange Format. + func test_cxpDecoder_decodesISO8601DateWithFractionalSeconds() { + let subject = JSONDecoder.cxpDecoder + let toDecode = #"{"credentialId":"credential","date":1697790414,"otherKey":"other","rpId":"rp"}"# + + struct JSONBody: Codable, Equatable { + let credentialID: String + let date: Date + let otherKey: String + let rpID: String + } + + let body = JSONBody( + credentialID: "credential", + date: Date(year: 2023, month: 10, day: 20, hour: 8, minute: 26, second: 54), + otherKey: "other", + rpID: "rp" + ) + + XCTAssertEqual( + try subject + .decode(JSONBody.self, from: Data(toDecode.utf8)), + body + ) + } + /// `JSONDecoder.defaultDecoder` can decode ISO8601 dates with fractional seconds. func test_defaultDecoder_decodesISO8601DateWithFractionalSeconds() { let subject = JSONDecoder.defaultDecoder diff --git a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoder+Bitwarden.swift b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoder+Bitwarden.swift index 4ece78d43..68c68c229 100644 --- a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoder+Bitwarden.swift +++ b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoder+Bitwarden.swift @@ -15,4 +15,32 @@ extension JSONEncoder { } return jsonEncoder }() + + /// The default `JSONEncoder` used to encode JSON payloads when in Credential Exchange flow. + static let cxpEncoder: JSONEncoder = { + let jsonEncoder = JSONEncoder() + jsonEncoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(Int(date.timeIntervalSince1970)) + } + jsonEncoder.keyEncodingStrategy = .custom { keys in + let key = keys.last!.stringValue + return AnyKey(stringValue: customTransformCodingKeyForCXP(key: key)) + } + return jsonEncoder + }() + + // MARK: Static Functions + + /// Transforms the keys from CXP format handled by the Bitwarden SDK into the keys that Apple expects. + static func customTransformCodingKeyForCXP(key: String) -> String { + return switch key { + case "credentialID": + "credentialId" + case "rpID": + "rpId" + default: + key + } + } } diff --git a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoderBitwardenTests.swift b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoderBitwardenTests.swift index 683c75063..774af9811 100644 --- a/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoderBitwardenTests.swift +++ b/BitwardenShared/Core/Platform/Services/API/Extensions/JSONEncoderBitwardenTests.swift @@ -5,6 +5,32 @@ import XCTest class JSONEncoderBitwardenTests: BitwardenTestCase { // MARK: Tests + /// `JSONEncoder.cxpEncoder` encodes for Credential Exchange Format. + func test_cxfpEncoder_encodesISO8601DateWithFractionalSeconds() throws { + let subject = JSONEncoder.cxpEncoder + subject.outputFormatting = .sortedKeys // added for test consistency so output is ordered. + + struct JSONBody: Codable { + let credentialID: String + let date: Date + let otherKey: String + let rpID: String + } + + let body = JSONBody( + credentialID: "credential", + date: Date(year: 2023, month: 10, day: 20, hour: 8, minute: 26, second: 54), + otherKey: "other", + rpID: "rp" + ) + let encodedData = try subject.encode(body) + let encodedString = String(data: encodedData, encoding: .utf8) + XCTAssertEqual( + encodedString, + #"{"credentialId":"credential","date":1697790414,"otherKey":"other","rpId":"rp"}"# + ) + } + /// `JSONEncoder.defaultEncoder` can encode ISO8601 dates without fractional seconds. func test_defaultEncoder_encodesISO8601DateWithoutFractionalSeconds() throws { let subject = JSONEncoder.defaultEncoder diff --git a/BitwardenShared/Core/Platform/Services/ClientService.swift b/BitwardenShared/Core/Platform/Services/ClientService.swift index 4db363bdd..5c01e2a85 100644 --- a/BitwardenShared/Core/Platform/Services/ClientService.swift +++ b/BitwardenShared/Core/Platform/Services/ClientService.swift @@ -28,7 +28,7 @@ protocol ClientService { /// - Parameter userId: The user ID mapped to the client instance. /// - Returns: A `ClientExportersProtocol` for vault export data tasks. /// - func exporters(for userId: String?) async throws -> ClientExportersServiceTemp + func exporters(for userId: String?) async throws -> ClientExportersProtocol /// Returns a `ClientGeneratorsProtocol` for generator data tasks. /// @@ -88,7 +88,7 @@ extension ClientService { /// Returns a `ClientExportersProtocol` for vault export data tasks. /// - func exporters() async throws -> ClientExportersServiceTemp { + func exporters() async throws -> ClientExportersProtocol { try await exporters(for: nil) } @@ -199,7 +199,7 @@ actor DefaultClientService: ClientService { try await client(for: userId).crypto() } - func exporters(for userId: String?) async throws -> ClientExportersServiceTemp { + func exporters(for userId: String?) async throws -> ClientExportersProtocol { try await client(for: userId).exporters() } @@ -366,7 +366,7 @@ protocol BitwardenSdkClient { func crypto() -> ClientCryptoProtocol /// Returns exporters. - func exporters() -> ClientExportersServiceTemp + func exporters() -> ClientExportersProtocol /// Returns generator operations. func generators() -> ClientGeneratorsProtocol @@ -392,7 +392,7 @@ extension Client: BitwardenSdkClient { crypto() as ClientCrypto } - func exporters() -> ClientExportersServiceTemp { + func exporters() -> ClientExportersProtocol { exporters() as ClientExporters } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 7e6146724..967606e99 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -82,6 +82,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The repository used by the application to manage generator data for the UI layer. let generatorRepository: GeneratorRepository + /// The repository used by the application to manage importing credential in Credential Exhange flow. + let importCiphersRepository: ImportCiphersRepository + /// The service used to access & store data on the device keychain. let keychainService: KeychainService @@ -192,6 +195,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// and extends the capabilities of the `Fido2UserInterface` from the SDK. /// - fido2CredentialStore: A store to be used on Fido2 flows to get/save credentials. /// - generatorRepository: The repository used by the application to manage generator data for the UI layer. + /// - importCiphersRepository: The repository used by the application to manage importing credential + /// in Credential Exhange flow. /// - keychainRepository: The repository used to manages keychain items. /// - keychainService: The service used to access & store data on the device keychain. /// - localAuthService: The service used by the application to evaluate local auth policies. @@ -241,6 +246,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le fido2CredentialStore: Fido2CredentialStore, fido2UserInterfaceHelper: Fido2UserInterfaceHelper, generatorRepository: GeneratorRepository, + importCiphersRepository: ImportCiphersRepository, keychainRepository: KeychainRepository, keychainService: KeychainService, localAuthService: LocalAuthService, @@ -290,6 +296,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.fido2CredentialStore = fido2CredentialStore self.fido2UserInterfaceHelper = fido2UserInterfaceHelper self.generatorRepository = generatorRepository + self.importCiphersRepository = importCiphersRepository self.keychainService = keychainService self.keychainRepository = keychainRepository self.localAuthService = localAuthService @@ -657,6 +664,17 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le vaultTimeoutService: vaultTimeoutService ) + let credentialManagerFactory = DefaultCredentialManagerFactory() + + let importCiphersRepository = DefaultImportCiphersRepository( + clientService: clientService, + credentialManagerFactory: credentialManagerFactory, + importCiphersService: DefaultImportCiphersService( + importCiphersAPIService: apiService + ), + syncService: syncService + ) + let userVerificationHelperFactory = DefaultUserVerificationHelperFactory( authRepository: authRepository, errorReporter: errorReporter, @@ -728,6 +746,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le fido2CredentialStore: fido2CredentialStore, fido2UserInterfaceHelper: fido2UserInterfaceHelper, generatorRepository: generatorRepository, + importCiphersRepository: importCiphersRepository, keychainRepository: keychainRepository, keychainService: keychainService, localAuthService: localAuthService, diff --git a/BitwardenShared/Core/Platform/Services/Services.swift b/BitwardenShared/Core/Platform/Services/Services.swift index 08a6e3a26..acfed175d 100644 --- a/BitwardenShared/Core/Platform/Services/Services.swift +++ b/BitwardenShared/Core/Platform/Services/Services.swift @@ -24,6 +24,7 @@ typealias Services = HasAPIService & HasFido2UserInterfaceHelper & HasFileAPIService & HasGeneratorRepository + & HasImportCiphersRepository & HasLocalAuthService & HasNFCReaderService & HasNotificationCenterService @@ -210,6 +211,13 @@ protocol HasGeneratorRepository { var generatorRepository: GeneratorRepository { get } } +/// Protocol for an object that provides a `ImportCiphersRepository`. +/// +protocol HasImportCiphersRepository { + /// The repository used by the application to manage importing credential in Credential Exhange flow. + var importCiphersRepository: ImportCiphersRepository { get } +} + /// Protocol for an object that provides a `LocalAuthService`. /// protocol HasLocalAuthService { diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientBuilder.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientBuilder.swift index d98af46a8..08c707058 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientBuilder.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientBuilder.swift @@ -37,7 +37,7 @@ class MockClient: BitwardenSdkClient { "" } - func exporters() -> any ClientExportersServiceTemp { + func exporters() -> any ClientExportersProtocol { clientExporters } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientService.swift index 7fed5ab4e..79d6978ff 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockClientService.swift @@ -44,7 +44,7 @@ class MockClientService: ClientService { mockCrypto } - func exporters(for userId: String?) -> ClientExportersServiceTemp { + func exporters(for userId: String?) -> ClientExportersProtocol { mockExporters } diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift index a80cee6b5..3388d1a66 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift @@ -24,6 +24,7 @@ extension ServiceContainer { fido2CredentialStore: Fido2CredentialStore = MockFido2CredentialStore(), fido2UserInterfaceHelper: Fido2UserInterfaceHelper = MockFido2UserInterfaceHelper(), generatorRepository: GeneratorRepository = MockGeneratorRepository(), + importCiphersRepository: ImportCiphersRepository = MockImportCiphersRepository(), httpClient: HTTPClient = MockHTTPClient(), keychainRepository: KeychainRepository = MockKeychainRepository(), keychainService: KeychainService = MockKeychainService(), @@ -78,6 +79,7 @@ extension ServiceContainer { fido2CredentialStore: fido2CredentialStore, fido2UserInterfaceHelper: fido2UserInterfaceHelper, generatorRepository: generatorRepository, + importCiphersRepository: importCiphersRepository, keychainRepository: keychainRepository, keychainService: keychainService, localAuthService: localAuthService, diff --git a/BitwardenShared/Core/Platform/Utilities/AnyKey.swift b/BitwardenShared/Core/Platform/Utilities/AnyKey.swift new file mode 100644 index 000000000..e119df5bd --- /dev/null +++ b/BitwardenShared/Core/Platform/Utilities/AnyKey.swift @@ -0,0 +1,16 @@ +/// `AnyKey` is a `CodingKey` type that can be used for encoding and decoding keys for custom +/// key decoding strategies. +struct AnyKey: CodingKey { + let stringValue: String + let intValue: Int? + + init(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init(intValue: Int) { + stringValue = String(intValue) + self.intValue = intValue + } +} diff --git a/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepository.swift b/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepository.swift new file mode 100644 index 000000000..a3eaa6f1c --- /dev/null +++ b/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepository.swift @@ -0,0 +1,143 @@ +import AuthenticationServices +import BitwardenSdk +import Foundation + +/// A protocol for a `ImportCiphersRepository` which manages importing credentials needed by the UI layer. +/// +protocol ImportCiphersRepository: AnyObject { + /// Performs an API request to import ciphers in the vault. + /// - Parameters: + /// - credentialImportToken: The token used in `ASCredentialImportManager` to get the credentials to import. + /// - onProgress: Closure to update progress. + /// - Returns: A dictionary containing the localized cipher type (key) and count (value) of that type + /// that was imported, e.g. ["Passwords": 3, "Cards": 2]. + @available(iOS 18.2, *) + func importCiphers( + credentialImportToken: UUID, + onProgress: @MainActor (Double) -> Void + ) async throws -> [ImportedCredentialsResult] +} + +// MARK: - DefaultImportCiphersRepository + +/// A default implementation of a `ImportCiphersRepository`. +/// +class DefaultImportCiphersRepository { + // MARK: Properties + + /// The service that handles common client functionality such as encryption and decryption. + let clientService: ClientService + + /// The factory to create credential managers. + let credentialManagerFactory: CredentialManagerFactory + + /// The service that manages importing credentials. + let importCiphersService: ImportCiphersService + + /// The service used to handle syncing vault data with the API. + let syncService: SyncService + + // MARK: Initialization + + /// Initialize a `DefaultImportCiphersRepository` + /// + /// - Parameters: + /// - clientService: The service that handles common client functionality such as encryption and decryption. + /// - credentialManagerFactory: A factory to create credential managers. + /// - importCiphersService: A service that manages importing credentials. + /// - syncService: The service used to handle syncing vault data with the API. + /// + init( + clientService: ClientService, + credentialManagerFactory: CredentialManagerFactory, + importCiphersService: ImportCiphersService, + syncService: SyncService + ) { + self.clientService = clientService + self.credentialManagerFactory = credentialManagerFactory + self.importCiphersService = importCiphersService + self.syncService = syncService + } +} + +// MARK: ImportCiphersRepository + +extension DefaultImportCiphersRepository: ImportCiphersRepository { + @available(iOS 18.2, *) + func importCiphers( // swiftlint:disable:this function_body_length + credentialImportToken: UUID, + onProgress: @MainActor (Double) -> Void + ) async throws -> [ImportedCredentialsResult] { + #if compiler(>=6.0.3) + + let credentialData = try await credentialManagerFactory.createImportManager().importCredentials( + token: credentialImportToken + ) + guard let accountData = credentialData.accounts.first else { + // this should never happen. + throw ImportCiphersRepositoryError.noDataFound + } + + let accountJsonData = try JSONEncoder.cxpEncoder.encode(accountData) + guard let accountJsonString = String(data: accountJsonData, encoding: .utf8) else { + // this should never happen. + throw ImportCiphersRepositoryError.dataEncodingFailed + } + + let ciphers = try await clientService.exporters().importCxf(payload: accountJsonString) + + await onProgress(0.3) + + _ = try await importCiphersService + .importCiphers( + ciphers: ciphers, + folders: [], + folderRelationships: [] + ) + + await onProgress(0.8) + + try await syncService.fetchSync(forceSync: true) + + let importedCredentialsCount: [ImportedCredentialsResult] = [ + ImportedCredentialsResult( + count: ciphers.count { $0.type == .login && $0.login?.fido2Credentials?.isEmpty != false }, + type: .password + ), + ImportedCredentialsResult( + count: ciphers.count { $0.type == .login && $0.login?.fido2Credentials?.isEmpty == false }, + type: .passkey + ), + ImportedCredentialsResult( + count: ciphers.count { $0.type == .card }, + type: .card + ), + ImportedCredentialsResult( + count: ciphers.count { $0.type == .identity }, + type: .identity + ), + ImportedCredentialsResult( + count: ciphers.count { $0.type == .secureNote }, + type: .secureNote + ), + ImportedCredentialsResult( + count: ciphers.count { $0.type == .sshKey }, + type: .sshKey + ), + ] + + await onProgress(1.0) + + return importedCredentialsCount.filter { !$0.isEmpty } + #else + return [] + #endif + } +} + +// MARK: - ImportCiphersRepositoryError + +enum ImportCiphersRepositoryError: Error { + case noDataFound + case dataEncodingFailed +} diff --git a/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepositoryTests.swift b/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepositoryTests.swift new file mode 100644 index 000000000..265e4db80 --- /dev/null +++ b/BitwardenShared/Core/Tools/Repositories/ImportCiphersRepositoryTests.swift @@ -0,0 +1,239 @@ +#if compiler(>=6.0.3) +import AuthenticationServices +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCiphersRepositoryTests + +class ImportCiphersRepositoryTests: BitwardenTestCase { + // MARK: Properties + + var clientService: MockClientService! + var credentialManagerFactory: MockCredentialManagerFactory! + var importCiphersService: MockImportCiphersService! + var syncService: MockSyncService! + var subject: ImportCiphersRepository! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + clientService = MockClientService() + credentialManagerFactory = MockCredentialManagerFactory() + importCiphersService = MockImportCiphersService() + syncService = MockSyncService() + subject = DefaultImportCiphersRepository( + clientService: clientService, + credentialManagerFactory: credentialManagerFactory, + importCiphersService: importCiphersService, + syncService: syncService + ) + } + + override func tearDown() { + super.tearDown() + + clientService = nil + credentialManagerFactory = nil + importCiphersService = nil + subject = nil + syncService = nil + } + + // MARK: Tests + + /// `importCiphers(credentialImportToken:progressDelegate:)` imports the ciphers, + /// updates progress report and returns the credentials result with each type count. + @MainActor + func test_importCiphers_success() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("Importing ciphers requires iOS 18.2") + } + + let credentialImportManager = MockCredentialImportManager() + credentialImportManager.importCredentialsResult = + .success( + ASExportedCredentialData( + accounts: [ + .fixture(items: [.fixture()]), + ] + ) + ) + credentialManagerFactory.importManager = credentialImportManager + + clientService.mockExporters.importCxfResult = .success([ + .fixture(id: "1", login: .fixture(), type: .login), + .fixture(id: "2", login: .fixture(), type: .login), + .fixture(id: "3", login: .fixture(fido2Credentials: [.fixture()]), type: .login), + .fixture(id: "4", type: .card), + .fixture(id: "5", type: .card), + .fixture(id: "6", type: .card), + .fixture(id: "7", type: .identity), + .fixture(id: "8", type: .secureNote), + .fixture(id: "9", type: .secureNote), + .fixture(id: "10", type: .sshKey), + ]) + + let expectedResults = [ + ImportedCredentialsResult(count: 2, type: .password), + ImportedCredentialsResult(count: 1, type: .passkey), + ImportedCredentialsResult(count: 3, type: .card), + ImportedCredentialsResult(count: 1, type: .identity), + ImportedCredentialsResult(count: 2, type: .secureNote), + ImportedCredentialsResult(count: 1, type: .sshKey), + ] + + var progressReports: [Double] = [] + let result = try await subject.importCiphers( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )!, + onProgress: { progress in progressReports.append(progress) } + ) + + XCTAssertNotNil(clientService.mockExporters.importCxfPayload) + XCTAssertTrue(importCiphersService.importCiphersCalled) + XCTAssertEqual(importCiphersService.importCiphersCiphers?.count, 10) + XCTAssertTrue(syncService.didFetchSync) + XCTAssertTrue(syncService.fetchSyncForceSync == true) + XCTAssertEqual(progressReports, [0.3, 0.8, 1.0]) + XCTAssertEqual(result, expectedResults) + } + + /// `importCiphers(credentialImportToken:progressDelegate:)` throws `noDataFound` + /// when there are no accounts after importing credentials. + @MainActor + func test_importCiphers_noDataFound() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("Importing ciphers requires iOS 18.2") + } + + let credentialImportManager = MockCredentialImportManager() + credentialImportManager.importCredentialsResult = + .success( + ASExportedCredentialData( + accounts: [] + ) + ) + credentialManagerFactory.importManager = credentialImportManager + + await assertAsyncThrows(error: ImportCiphersRepositoryError.noDataFound) { + _ = try await subject.importCiphers( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )!, + onProgress: { _ in } + ) + } + } + + /// `importCiphers(credentialImportToken:progressDelegate:)` throws when calling + /// the SDK to import the ciphers. + @MainActor + func test_importCiphers_sdkThrows() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("Importing ciphers requires iOS 18.2") + } + + let credentialImportManager = MockCredentialImportManager() + credentialImportManager.importCredentialsResult = + .success( + ASExportedCredentialData( + accounts: [ + .fixture(items: [.fixture()]), + ] + ) + ) + credentialManagerFactory.importManager = credentialImportManager + + clientService.mockExporters.importCxfResult = .failure(BitwardenTestError.example) + + await assertAsyncThrows(error: BitwardenTestError.example) { + _ = try await subject.importCiphers( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )!, + onProgress: { _ in } + ) + } + } + + /// `importCiphers(credentialImportToken:progressDelegate:)` throws when calling the API + /// to import the ciphers. + @MainActor + func test_importCiphers_throwsWhenImportingCiphersAPI() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("Importing ciphers requires iOS 18.2") + } + + let credentialImportManager = MockCredentialImportManager() + credentialImportManager.importCredentialsResult = + .success( + ASExportedCredentialData( + accounts: [ + .fixture(items: [.fixture()]), + ] + ) + ) + credentialManagerFactory.importManager = credentialImportManager + + clientService.mockExporters.importCxfResult = .success([ + .fixture(id: "1", login: .fixture(), type: .login), + .fixture(id: "2", login: .fixture(), type: .login), + ]) + + importCiphersService.importCiphersError = BitwardenTestError.example + + var progressReports: [Double] = [] + await assertAsyncThrows(error: BitwardenTestError.example) { + _ = try await subject.importCiphers( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )!, + onProgress: { progress in progressReports.append(progress) } + ) + } + XCTAssertEqual(progressReports, [0.3]) + } + + /// `importCiphers(credentialImportToken:progressDelegate:)` throws when syncing after + /// importing the ciphers. + @MainActor + func test_importCiphers_throwsWhenSyncing() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("Importing ciphers requires iOS 18.2") + } + + let credentialImportManager = MockCredentialImportManager() + credentialImportManager.importCredentialsResult = + .success( + ASExportedCredentialData( + accounts: [ + .fixture(items: [.fixture()]), + ] + ) + ) + credentialManagerFactory.importManager = credentialImportManager + + clientService.mockExporters.importCxfResult = .success([ + .fixture(id: "1", login: .fixture(), type: .login), + .fixture(id: "2", login: .fixture(), type: .login), + ]) + + syncService.fetchSyncResult = .failure(BitwardenTestError.example) + + var progressReports: [Double] = [] + await assertAsyncThrows(error: BitwardenTestError.example) { + _ = try await subject.importCiphers( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )!, + onProgress: { progress in progressReports.append(progress) } + ) + } + XCTAssertEqual(progressReports, [0.3, 0.8]) + } +} +#endif diff --git a/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockImportCiphersRepository.swift b/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockImportCiphersRepository.swift new file mode 100644 index 000000000..4aec5628c --- /dev/null +++ b/BitwardenShared/Core/Tools/Repositories/TestHelpers/MockImportCiphersRepository.swift @@ -0,0 +1,18 @@ +import BitwardenSdk +import Foundation + +@testable import BitwardenShared + +class MockImportCiphersRepository: ImportCiphersRepository { + var importCiphersResult = InvocationMockerWithThrowingResult() + .withResult([]) + var progressReport: Double = 0 + + func importCiphers( + credentialImportToken: UUID, + onProgress: @MainActor (Double) -> Void + ) async throws -> [ImportedCredentialsResult] { + await onProgress(progressReport) + return try importCiphersResult.invoke(param: credentialImportToken) + } +} diff --git a/BitwardenShared/Core/Tools/Services/ImportCiphersService.swift b/BitwardenShared/Core/Tools/Services/ImportCiphersService.swift new file mode 100644 index 000000000..09971e581 --- /dev/null +++ b/BitwardenShared/Core/Tools/Services/ImportCiphersService.swift @@ -0,0 +1,56 @@ +import BitwardenSdk +import Combine +import Foundation + +// MARK: - ImportCiphersService + +/// A protocol for a `ImportCiphersService` which manages importing credentials. +/// +protocol ImportCiphersService { + /// Performs an API request to import ciphers in the vault. + /// - Parameters: + /// - ciphers: The ciphers to import. + /// - folders: The folders to import. + /// - folderRelationships: The cipher<->folder relationships map. The key is the cipher index + /// and the value is the folder index in their respective arrays. + func importCiphers( + ciphers: [Cipher], + folders: [Folder], + folderRelationships: [(key: Int, value: Int)] + ) async throws +} + +// MARK: - DefaultImportCiphersService + +class DefaultImportCiphersService: ImportCiphersService { + // MARK: Properties + + /// The service used to make import ciphers related API requests. + private let importCiphersAPIService: ImportCiphersAPIService + + // MARK: Initialization + + /// Initialize a `DefaultCipherService`. + /// + /// - Parameters: + /// - importCiphersAPIService: The service used to make import ciphers related API requests. + /// + init(importCiphersAPIService: ImportCiphersAPIService) { + self.importCiphersAPIService = importCiphersAPIService + } +} + +extension DefaultImportCiphersService { + func importCiphers( + ciphers: [Cipher], + folders: [Folder], + folderRelationships: [(key: Int, value: Int)] + ) async throws { + _ = try await importCiphersAPIService + .importCiphers( + ciphers: ciphers, + folders: folders, + folderRelationships: folderRelationships + ) + } +} diff --git a/BitwardenShared/Core/Tools/Services/ImportCiphersServiceTests.swift b/BitwardenShared/Core/Tools/Services/ImportCiphersServiceTests.swift new file mode 100644 index 000000000..9c6c490fb --- /dev/null +++ b/BitwardenShared/Core/Tools/Services/ImportCiphersServiceTests.swift @@ -0,0 +1,50 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCiphersServiceTests + +class ImportCiphersServiceTests: BitwardenTestCase { + // MARK: Properties + + var client: MockHTTPClient! + var importCiphersAPIService: ImportCiphersAPIService! + var subject: ImportCiphersService! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + client = MockHTTPClient() + importCiphersAPIService = APIService(client: client) + subject = DefaultImportCiphersService(importCiphersAPIService: importCiphersAPIService) + } + + override func tearDown() { + super.tearDown() + + client = nil + importCiphersAPIService = nil + subject = nil + } + + // MARK: Tests + + /// `importCiphers(ciphers:folders:folderRelationships:)` import the ciphers calling the API. + func test_importCiphers_succeeds() async throws { + client.results = [.httpSuccess(testData: .emptyResponse)] + try await subject.importCiphers(ciphers: [.fixture()], folders: [], folderRelationships: []) + let request = try XCTUnwrap(client.requests.first) + XCTAssertEqual(request.url.absoluteString, "https://example.com/api/ciphers/import") + XCTAssertEqual(request.method, .post) + } + + /// `importCiphers(ciphers:folders:folderRelationships:)` throws when calling the API. + func test_importCiphers_throws() async throws { + client.results = [.httpFailure(BitwardenTestError.example)] + await assertAsyncThrows(error: BitwardenTestError.example) { + try await subject.importCiphers(ciphers: [.fixture()], folders: [], folderRelationships: []) + } + } +} diff --git a/BitwardenShared/Core/Tools/Services/Temp/ClientExportersProtocol+Extensions.swift b/BitwardenShared/Core/Tools/Services/Temp/ClientExportersProtocol+Extensions.swift deleted file mode 100644 index 566e8f8a8..000000000 --- a/BitwardenShared/Core/Tools/Services/Temp/ClientExportersProtocol+Extensions.swift +++ /dev/null @@ -1,39 +0,0 @@ -// swiftlint:disable:this file_name - -import BitwardenSdk - -/// Temporary protocol of `ClientExportersProtocol` until the SDK PR gets merged and is available for CI -/// https://github.com/bitwarden/sdk-internal/pull/32 -protocol ClientExportersServiceTemp: AnyObject { - /// Exports ciphers with an account in Credential Exchange flow. - func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String - - /// Exports organization vault in a given format. - func exportOrganizationVault(collections: [Collection], ciphers: [Cipher], format: ExportFormat) throws -> String - - /// Exports vault with a given format. - func exportVault(folders: [Folder], ciphers: [Cipher], format: ExportFormat) throws -> String - - /// Imports ciphers in Credential Exchange flow. - func importCxf(payload: String) throws -> [BitwardenSdk.Cipher] -} - -/// Mocking the responses of the export CXP flow until the SDK PR gets merged. -extension ClientExporters: ClientExportersServiceTemp { - func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String { - """ - {"items":[{"modifiedAt":1732226366,"creationAt":1732226366,"title":"GitHub","credentials":[{"urls":["github.com"],"password":{"id":"RTEzRDEwQjctRTdCQy00QTI3LTgwNDAtRjgxMzNBOTMxMjhC","fieldType":"concealed-string","value":"adsfasf"},"type":"basic-auth","username":{"fieldType":"string","id":"NTlBMUFBNUYtODE5My00QUIzLThGRjYtOEFCRUQ5MUQxNUZG","value":"TestCXP1"}}],"id":"MjZDQzQwQTQtQUZDQS00NEIzLUEwNjAtMUMyNUUzNTc1RTZB","type":"login"},{"type":"login","modifiedAt":1732226380,"id":"NEMzOTY4MTItRTMxMi00NUExLUE4NDYtRUFENEZDMTkyMDJC","creationAt":1732226380,"title":"Google","credentials":[{"urls":["google.com"],"type":"basic-auth","username":{"id":"MTdCOUI5NTUtM0FGOC00RDYzLUEwN0UtQjJFMjk1MTM1NDlC","fieldType":"string","value":"TestCXPGoogle"},"password":{"fieldType":"concealed-string","value":"1o23j1po3ij1o","id":"QTU2NDVDMTktMTgzQy00OEJELUI4NTMtNzg4NjYzRDk2NzI1"}}]}],"id":"RDQxRjU3QTYtM0NFNi00MTI5LUI0MkUtNUZBOUY0NkU3QTFD","collections":[],"email":"","userName":""} - """ // swiftlint:disable:previous line_length - } - - func importCxf(payload: String) throws -> [BitwardenSdk.Cipher] { - [] - } -} - -/// A temporary SDK Account to be used when exporting CXP. -public struct BitwardenSdkAccount { - let id: String - let email: String - let name: String? -} diff --git a/BitwardenShared/Core/Tools/Services/TestHelpers/MockImportCiphersService.swift b/BitwardenShared/Core/Tools/Services/TestHelpers/MockImportCiphersService.swift new file mode 100644 index 000000000..ea1f15512 --- /dev/null +++ b/BitwardenShared/Core/Tools/Services/TestHelpers/MockImportCiphersService.swift @@ -0,0 +1,25 @@ +import BitwardenSdk + +@testable import BitwardenShared + +class MockImportCiphersService: ImportCiphersService { + var importCiphersCalled = false + var importCiphersCiphers: [Cipher]? + var importCiphersError: Error? + var importCiphersFolders: [Folder]? + var importCiphersFolderRelationships: [(key: Int, value: Int)]? + + func importCiphers( + ciphers: [Cipher], + folders: [Folder], + folderRelationships: [(key: Int, value: Int)] + ) async throws { + importCiphersCalled = true + importCiphersCiphers = ciphers + importCiphersFolders = folders + importCiphersFolderRelationships = folderRelationships + if let importCiphersError { + throw importCiphersError + } + } +} diff --git a/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactory.swift b/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactory.swift new file mode 100644 index 000000000..ccf157479 --- /dev/null +++ b/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactory.swift @@ -0,0 +1,36 @@ +import AuthenticationServices +import Foundation + +protocol CredentialManagerFactory { + @available(iOS 18.2, *) + func createImportManager() -> CredentialImportManager +} + +struct DefaultCredentialManagerFactory: CredentialManagerFactory { + @available(iOS 18.2, *) + func createImportManager() -> any CredentialImportManager { + ASCredentialImportManager() + } +} + +protocol CredentialImportManager: AnyObject { + @available(iOS 18.2, *) + func importCredentials(token: UUID) async throws -> ASExportedCredentialData +} + +#if compiler(>=6.0.3) + +@available(iOS 18.2, *) +extension ASCredentialImportManager: CredentialImportManager {} + +#else + +class ASCredentialImportManager: CredentialImportManager { + func importCredentials(token: UUID) async throws -> ASExportedCredentialData { + ASExportedCredentialData() + } +} + +struct ASExportedCredentialData {} + +#endif diff --git a/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactoryTests.swift b/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactoryTests.swift new file mode 100644 index 000000000..7db94f8d5 --- /dev/null +++ b/BitwardenShared/Core/Tools/Utilities/CredentialManagerFactoryTests.swift @@ -0,0 +1,37 @@ +#if compiler(>=6.0.3) +import AuthenticationServices +#endif +import XCTest + +@testable import BitwardenShared + +// MARK: - CredentialManagerFactoryTests + +@available(iOS 18.2, *) +class CredentialManagerFactoryTests: BitwardenTestCase { + // MARK: Properties + + var subject: CredentialManagerFactory! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + subject = DefaultCredentialManagerFactory() + } + + override func tearDown() { + super.tearDown() + + subject = nil + } + + // MARK: Tests + + func test_createImportManager() { + let manager = subject.createImportManager() + + XCTAssertTrue(manager is ASCredentialImportManager) + } +} diff --git a/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResult.swift b/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResult.swift new file mode 100644 index 000000000..1b767c913 --- /dev/null +++ b/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResult.swift @@ -0,0 +1,53 @@ +/// Represents the result of imported credentials of one type. +struct ImportedCredentialsResult: Equatable, Sendable { + // MARK: Types + + /// The available imported credential type. + enum ImportedCredentialType: String, Equatable, Sendable { + case card = "Card" + case identity = "Identity" + case passkey = "Passkey" + case password = "Password" + case secureNote = "SecureNote" + case sshKey = "SSHKey" + } + + // MARK: Properties + + /// The number of credentials imported for the type + let count: Int + + /// The localized type in plural. + var localizedTypePlural: String { + return switch type { + case .card: + Localizations.cards + case .identity: + Localizations.identities + case .passkey: + Localizations.passkeys + case .password: + Localizations.passwords + case .secureNote: + Localizations.secureNotes + case .sshKey: + Localizations.sshKeys + } + } + + /// Whether the result has no imported credentials for the type. + var isEmpty: Bool { + count == 0 // swiftlint:disable:this empty_count + } + + /// The credential type imported. + let type: ImportedCredentialType +} + +// MARK: - Identifiable + +extension ImportedCredentialsResult: Identifiable { + public var id: ImportedCredentialType { + type + } +} diff --git a/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResultTests.swift b/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResultTests.swift new file mode 100644 index 000000000..a7846f0bb --- /dev/null +++ b/BitwardenShared/Core/Tools/Utilities/ImportedCredentialsResultTests.swift @@ -0,0 +1,51 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportedCredentialsResultTests + +class ImportedCredentialsResultTests: BitwardenTestCase { + // MARK: Properties + + var subject: ImportedCredentialsResult! + + // MARK: Setup & Teardown + + override func tearDown() { + super.tearDown() + + subject = nil + } + + // MARK: Tests + + /// `localizedTypePlural` returns the localized string for the type in plural. + func test_localizedTypePlural() { + subject = ImportedCredentialsResult(count: 1, type: .card) + XCTAssertEqual(subject.localizedTypePlural, Localizations.cards) + + subject = ImportedCredentialsResult(count: 1, type: .identity) + XCTAssertEqual(subject.localizedTypePlural, Localizations.identities) + + subject = ImportedCredentialsResult(count: 1, type: .passkey) + XCTAssertEqual(subject.localizedTypePlural, Localizations.passkeys) + + subject = ImportedCredentialsResult(count: 1, type: .password) + XCTAssertEqual(subject.localizedTypePlural, Localizations.passwords) + + subject = ImportedCredentialsResult(count: 1, type: .secureNote) + XCTAssertEqual(subject.localizedTypePlural, Localizations.secureNotes) + + subject = ImportedCredentialsResult(count: 1, type: .sshKey) + XCTAssertEqual(subject.localizedTypePlural, Localizations.sshKeys) + } + + /// `getter:isEmpty` returns `true` is no credential were imported, `false` otherwise. + func test_isEmpty() { + subject = ImportedCredentialsResult(count: 0, type: .identity) + XCTAssertTrue(subject.isEmpty) + + subject = ImportedCredentialsResult(count: 1, type: .card) + XCTAssertFalse(subject.isEmpty) + } +} diff --git a/BitwardenShared/Core/Tools/Utilities/TestHelpers/MockCredentialManagerFactory.swift b/BitwardenShared/Core/Tools/Utilities/TestHelpers/MockCredentialManagerFactory.swift new file mode 100644 index 000000000..0669bc51c --- /dev/null +++ b/BitwardenShared/Core/Tools/Utilities/TestHelpers/MockCredentialManagerFactory.swift @@ -0,0 +1,25 @@ +#if compiler(>=6.0.3) +import AuthenticationServices +import BitwardenSdk + +@testable import BitwardenShared + +class MockCredentialManagerFactory: CredentialManagerFactory { + var importManager: CredentialImportManager? + + @available(iOS 18.2, *) + func createImportManager() -> CredentialImportManager { + importManager ?? MockCredentialImportManager() + } +} + +@available(iOS 18.2, *) +class MockCredentialImportManager: CredentialImportManager { + var importCredentialsResult: Result = .failure(BitwardenTestError.example) + + @available(iOS 18.2, *) + func importCredentials(token: UUID) async throws -> ASExportedCredentialData { + try importCredentialsResult.get() + } +} +#endif diff --git a/BitwardenShared/Core/Vault/Services/ExportVaultService.swift b/BitwardenShared/Core/Vault/Services/ExportVaultService.swift index e77c086d1..73ff3769c 100644 --- a/BitwardenShared/Core/Vault/Services/ExportVaultService.swift +++ b/BitwardenShared/Core/Vault/Services/ExportVaultService.swift @@ -204,7 +204,7 @@ class DefultExportVaultService: ExportVaultService { .filter { $0.deletedDate == nil } let account = try await stateService.getAccount(userId: nil) - let sdkAccount = BitwardenSdkAccount( + let sdkAccount = BitwardenSdk.Account( id: account.profile.userId, email: account.profile.email, name: account.profile.name diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableAccount+Extensions.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableAccount+Extensions.swift index 6176b2eba..95ff6691b 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableAccount+Extensions.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableAccount+Extensions.swift @@ -4,6 +4,29 @@ import AuthenticationServices @available(iOS 18.2, *) extension ASImportableAccount { + // MARK: Static methods + + /// Provides a fixture for `ASImportableAccount` + static func fixture( + id: Data = Data(capacity: 16), + userName: String = "", + email: String = "", + fullName: String? = nil, + collections: [ASImportableCollection] = [], + items: [ASImportableItem] = [] + ) -> ASImportableAccount { + ASImportableAccount( + id: id, + userName: userName, + email: email, + fullName: fullName, + collections: collections, + items: items + ) + } + + // MARK: Methods + /// Dumps the content of the `ASImportableAccount` into lines which can be used with /// inline snapshot assertion. func dump() -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableItem+Extensions.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableItem+Extensions.swift new file mode 100644 index 000000000..b9fcb5b1c --- /dev/null +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/ASImportableItem+Extensions.swift @@ -0,0 +1,29 @@ +#if compiler(>=6.0.3) +import AuthenticationServices + +@available(iOS 18.2, *) +extension ASImportableItem { + /// Provides a fixture for `ASImportableItem`. + static func fixture( + id: Data = Data(capacity: 16), + created: Date = .now, + lastModified: Date = .now, + type: ASImportableItem.ItemType = .login, + title: String = "", + subtitle: String? = nil, + credentials: [ASImportableCredential] = [], + tags: [String] = [] + ) -> ASImportableItem { + ASImportableItem( + id: id, + created: created, + lastModified: lastModified, + type: type, + title: title, + subtitle: subtitle, + credentials: credentials, + tags: tags + ) + } +} +#endif diff --git a/BitwardenShared/Core/Vault/Services/TestHelpers/MockClientExporters.swift b/BitwardenShared/Core/Vault/Services/TestHelpers/MockClientExporters.swift index 3a039df16..f91135d4a 100644 --- a/BitwardenShared/Core/Vault/Services/TestHelpers/MockClientExporters.swift +++ b/BitwardenShared/Core/Vault/Services/TestHelpers/MockClientExporters.swift @@ -10,7 +10,7 @@ class MockClientExporters { // MARK: Properties /// The account used in `exportOrganizationVault(_:)`. - var account: BitwardenSdkAccount? + var account: BitwardenSdk.Account? /// The ciphers exported in a call to `exportVault(_:)` or `exportOrganizationVault(_:)` /// or `exportOrganizationVault(_:)`. @@ -28,23 +28,23 @@ class MockClientExporters { /// The result of a call to `exportVault(_:)` var exportVaultResult: Result = .failure(BitwardenTestError.example) - /// The folders exported in a call to `exportVault(_:)`. - var folders = [BitwardenSdk.Folder]() - - /// The format of the export in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`. - var format: BitwardenSdk.ExportFormat? - /// The payload passed to `importCxf(payload:)` var importCxfPayload: String? /// The result of a call to `importCxf(payload:)` var importCxfResult: Result<[BitwardenSdk.Cipher], Error> = .failure(BitwardenTestError.example) + + /// The folders exported in a call to `exportVault(_:)`. + var folders = [BitwardenSdk.Folder]() + + /// The format of the export in a call to `exportVault(_:)` or `exportOrganizationVault(_:)`. + var format: BitwardenSdk.ExportFormat? } // MARK: - ClientExportersProtocol -extension MockClientExporters: ClientExportersServiceTemp { - func exportCxf(account: BitwardenSdkAccount, ciphers: [BitwardenSdk.Cipher]) throws -> String { +extension MockClientExporters: ClientExportersProtocol { + func exportCxf(account: BitwardenSdk.Account, ciphers: [BitwardenSdk.Cipher]) throws -> String { self.account = account self.ciphers = ciphers return try exportCxfResult.get() diff --git a/BitwardenShared/UI/Platform/Application/AppProcessor.swift b/BitwardenShared/UI/Platform/Application/AppProcessor.swift index 8a5363c8c..fe65fb678 100644 --- a/BitwardenShared/UI/Platform/Application/AppProcessor.swift +++ b/BitwardenShared/UI/Platform/Application/AppProcessor.swift @@ -126,14 +126,7 @@ public class AppProcessor { route = await getOtpAuthUrlRoute(url: url) } guard let route else { return } - - if let userId = try? await services.stateService.getActiveAccountId(), - !services.vaultTimeoutService.isLocked(userId: userId), - await (try? services.vaultTimeoutService.hasPassedSessionTimeout(userId: userId)) == false { - coordinator?.navigate(to: route) - } else { - await coordinator?.handleEvent(.setAuthCompletionRoute(route)) - } + await checkIfLockedAndPerformNavigation(route: route) } /// Starts the application flow by navigating the user to the first flow. @@ -219,10 +212,11 @@ public class AppProcessor { /// Handles importing credentials using Credential Exchange Protocol. /// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`. @available(iOSApplicationExtension 18.2, *) - public func handleImportCredentials(credentialImportToken: UUID) { - // TODO: PM-14800 Move this to a specific view to handle importing process - // and handle credential data. - // let credentialData = try await ASCredentialImportManager().importCredentials(token: credentialImportToken) + public func handleImportCredentials(credentialImportToken: UUID) async { + let route = AppRoute.tab(.vault(.importCXP( + .importCredentials(credentialImportToken: credentialImportToken) + ))) + await checkIfLockedAndPerformNavigation(route: route) } // MARK: Autofill Methods @@ -392,6 +386,19 @@ extension AppProcessor { } } + /// Checks if the vault is locked and performs the navigation to the `AppRoute` + /// or sets it as the auth completion route. + /// - Parameter route: The `AppRoute` to go to. + private func checkIfLockedAndPerformNavigation(route: AppRoute) async { + if let userId = try? await services.stateService.getActiveAccountId(), + !services.vaultTimeoutService.isLocked(userId: userId), + await (try? services.vaultTimeoutService.hasPassedSessionTimeout(userId: userId)) == false { + coordinator?.navigate(to: route) + } else { + await coordinator?.handleEvent(.setAuthCompletionRoute(route)) + } + } + /// If the native create account feature flag and the autofill extension are enabled, this marks /// any user's autofill account setup completed. This should be called on app startup. /// diff --git a/BitwardenShared/UI/Platform/Application/Appearance/StyleGuideFont.swift b/BitwardenShared/UI/Platform/Application/Appearance/StyleGuideFont.swift index 29617481d..b61bfb181 100644 --- a/BitwardenShared/UI/Platform/Application/Appearance/StyleGuideFont.swift +++ b/BitwardenShared/UI/Platform/Application/Appearance/StyleGuideFont.swift @@ -49,6 +49,9 @@ extension StyleGuideFont { // MARK: - StyleGuideFont Constants extension StyleGuideFont { + /// The font for the huge title style. + static let hugeTitle = StyleGuideFont.dmSans(lineHeight: 41, size: 34, textStyle: .largeTitle) + /// The font for the large title style. static let largeTitle = StyleGuideFont.dmSans(lineHeight: 32, size: 26, textStyle: .largeTitle) @@ -211,6 +214,8 @@ struct StyleGuideFont_Previews: PreviewProvider { static var previews: some View { HStack { VStack(alignment: .trailing, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle) Text("Large Title") .styleGuide(.largeTitle) Text("Title") @@ -239,6 +244,8 @@ struct StyleGuideFont_Previews: PreviewProvider { .styleGuide(.caption2Monospaced) } VStack(alignment: .leading, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle, weight: .semibold) Text("Large Title") .styleGuide(.largeTitle, weight: .semibold) Text("Title") @@ -272,6 +279,8 @@ struct StyleGuideFont_Previews: PreviewProvider { HStack { VStack(alignment: .trailing, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle) Text("Large Title") .styleGuide(.largeTitle) Text("Title") @@ -300,6 +309,8 @@ struct StyleGuideFont_Previews: PreviewProvider { .styleGuide(.caption2Monospaced) } VStack(alignment: .leading, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle, isItalic: true) Text("Large Title") .styleGuide(.largeTitle, isItalic: true) Text("Title") @@ -333,6 +344,8 @@ struct StyleGuideFont_Previews: PreviewProvider { HStack { VStack(alignment: .trailing, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle) Text("Large Title") .styleGuide(.largeTitle) Text("Title") @@ -361,6 +374,8 @@ struct StyleGuideFont_Previews: PreviewProvider { .styleGuide(.caption2Monospaced) } VStack(alignment: .leading, spacing: 8) { + Text("Huge Title") + .styleGuide(.hugeTitle, weight: .semibold, isItalic: true) Text("Large Title") .styleGuide(.largeTitle, weight: .semibold, isItalic: true) Text("Title") diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.1.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.1.png index 357176228..73418e362 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.1.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.2.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.2.png index 5c75eaf7b..1009a0874 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.2.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.2.png differ diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.3.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.3.png index 301fc4305..b6942beff 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.3.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont.3.png differ diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.1.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.1.png index ab32dc2bd..5195d4d56 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.1.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.1.png differ diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.2.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.2.png index 49caf8f34..0c2ff23c1 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.2.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.2.png differ diff --git a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.3.png b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.3.png index 0d4a4e375..d88aa0077 100644 Binary files a/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.3.png and b/BitwardenShared/UI/Platform/Application/Appearance/__Snapshots__/StyleGuideFontTests/test_snapshot_styleGuideFont_largeText.3.png differ diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/Contents.json b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/Contents.json new file mode 100644 index 000000000..2f8cc19e3 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "file_upload.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/file_upload.pdf b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/file_upload.pdf new file mode 100644 index 000000000..f9799ec88 Binary files /dev/null and b/BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Icons/file-upload24.imageset/file_upload.pdf differ diff --git a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 05d15b926..dd6002b3b 100644 --- a/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -1064,3 +1064,14 @@ "FailedToAutofillItem" = "Failed to autofill item %1$@"; "ExportingFailed" = "Exporting failed"; "YouMayNeedToEnableDevicePasscodeOrBiometrics" = "You may need to enable device passcode or biometrics."; +"StartImportCXPDescriptionLong" = "Import passwords, passkeys, credit cards, and any personal identity information from another password manager.\n\nYour data will **not** be deleted from your previous provider."; +"ImportPasswords" = "Import passwords"; +"ImportingEllipsis" = "Importing..."; +"AreYouSureYouWantToCancelTheImportProcessQuestionMark" = "Are you sure you want to cancel the import process?"; +"ImportFailed" = "Import failed"; +"ItemsSuccessfullyImported" = "%1$@ items successfully imported"; +"ThereWasAnIssueImportingAllOfYourPasswordsNoDataWasDeleted" = "There was an issue importing all of your passwords.\n\nNo data was deleted."; +"RetryImport" = "Retry import"; +"ShowVault" = "Show vault"; +"ImportNotAvailable" = "Import not available"; +"ImportingFromAnotherProviderIsNotAvailableForThisDevice" = "Importing from another provider is not available for this device."; diff --git a/BitwardenShared/UI/Platform/Application/TestHelpers/MockProgressDelegate.swift b/BitwardenShared/UI/Platform/Application/TestHelpers/MockProgressDelegate.swift new file mode 100644 index 000000000..60b783272 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/TestHelpers/MockProgressDelegate.swift @@ -0,0 +1,9 @@ +@testable import BitwardenShared + +class MockProgressDelegate: ProgressDelegate { + var progressReports: [Double] = [] + + func report(progress: Double) { + progressReports.append(progress) + } +} diff --git a/BitwardenShared/UI/Platform/Application/Utilities/ProgressDelegate.swift b/BitwardenShared/UI/Platform/Application/Utilities/ProgressDelegate.swift new file mode 100644 index 000000000..a55965bb1 --- /dev/null +++ b/BitwardenShared/UI/Platform/Application/Utilities/ProgressDelegate.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A protocol to report progress. +@MainActor +protocol ProgressDelegate: AnyObject { + /// Reports progress in an operation. + /// - Parameter progress: The progress being made in an operation. + func report(progress: Double) +} diff --git a/BitwardenShared/UI/Platform/Application/Views/PageHeaderView.swift b/BitwardenShared/UI/Platform/Application/Views/PageHeaderView.swift index 05873ecdd..f93693ef1 100644 --- a/BitwardenShared/UI/Platform/Application/Views/PageHeaderView.swift +++ b/BitwardenShared/UI/Platform/Application/Views/PageHeaderView.swift @@ -5,6 +5,17 @@ import SwiftUI /// A view that renders a header for page. This support displaying an image, title, and message. /// struct PageHeaderView: View { + // MARK: Types + + /// The style to apply to the `PageHeaderView`. + enum StyleMode { + /// Normal font style with illustration as image. + case normalWithIllustration + + /// Large font style with tinted icon as image. + case largeWithTintedIcon + } + // MARK: Properties /// The image to display in the page header. @@ -16,6 +27,9 @@ struct PageHeaderView: View { /// The title to display in the page header. let title: String + /// The style to apply to this view. + let style: StyleMode + /// An environment variable for getting the vertical size class of the view. @Environment(\.verticalSizeClass) var verticalSizeClass @@ -23,16 +37,40 @@ struct PageHeaderView: View { var body: some View { dynamicStackView { - image - .resizable() - .frame(width: 100, height: 100) + switch style { + case .normalWithIllustration: + image + .resizable() + .frame(width: 100, height: 100) + case .largeWithTintedIcon: + image + .resizable() + .frame(width: 70, height: 70) + .foregroundStyle(Asset.Colors.iconSecondary.swiftUIColor) + } VStack(spacing: 16) { Text(title) - .styleGuide(.title2, weight: .bold) - - Text(message) - .styleGuide(.body) + .apply { text in + switch style { + case .normalWithIllustration: + text.styleGuide(.title2, weight: .bold) + case .largeWithTintedIcon: + text.styleGuide(.hugeTitle, weight: .bold) + } + } + .accessibilityIdentifier("HeaderTitle") + + Text(LocalizedStringKey(message)) + .apply { text in + switch style { + case .normalWithIllustration: + text.styleGuide(.body) + case .largeWithTintedIcon: + text.styleGuide(.title2) + } + } + .accessibilityIdentifier("HeaderMessage") } } .foregroundStyle(Asset.Colors.textPrimary.swiftUIColor) @@ -47,11 +85,13 @@ struct PageHeaderView: View { /// - image: The image to display. /// - title: The title to display. /// - message: The message to display. + /// - style: The style to use for this view. /// - init(image: Image, title: String, message: String) { + init(image: Image, title: String, message: String, style: StyleMode = .normalWithIllustration) { self.image = image self.message = message self.title = title + self.style = style } /// Initialize a `PageHeaderView`. @@ -60,11 +100,13 @@ struct PageHeaderView: View { /// - image: The image asset to display. /// - title: The title to display. /// - message: The message to display. + /// - style: The style to use for this view. /// - init(image: ImageAsset, title: String, message: String) { + init(image: ImageAsset, title: String, message: String, style: StyleMode = .normalWithIllustration) { self.image = image.swiftUIImage self.message = message self.title = title + self.style = style } // MARK: Private @@ -85,11 +127,21 @@ struct PageHeaderView: View { // MARK: - Previews #if DEBUG -#Preview("PageHeader") { +#Preview("PageHeader Normal") { PageHeaderView( image: Asset.Images.Illustrations.biometricsPhone, title: Localizations.setUpUnlock, message: Localizations.setUpBiometricsOrChooseAPinCodeToQuicklyAccessYourVaultAndAutofillYourLogins ) } + +#Preview("PageHeader Large") { + PageHeaderView( + image: Asset.Images.plus24, + title: Localizations.setUpUnlock, + message: Localizations.setUpBiometricsOrChooseAPinCodeToQuicklyAccessYourVaultAndAutofillYourLogins, + style: .largeWithTintedIcon + ) +} + #endif diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPEffect.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPEffect.swift new file mode 100644 index 000000000..21e5c6699 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPEffect.swift @@ -0,0 +1,16 @@ +import Foundation + +// MARK: - ImportCXPEffect + +/// Effects that can be processed by a `ImportCXPProcessor`. +/// +enum ImportCXPEffect: Equatable { + /// The view appeared. + case appeared + + /// User wants to cancel the import process. + case cancel + + /// The main button was tapped. + case mainButtonTapped +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessor.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessor.swift new file mode 100644 index 000000000..7abf123ee --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessor.swift @@ -0,0 +1,123 @@ +import AuthenticationServices +import BitwardenSdk + +// MARK: - ImportCXPProcessor + +/// The processor used to manage state and handle actions/effects for the Credential Exchange import screen. +/// +class ImportCXPProcessor: StateProcessor { + // MARK: Types + + typealias Services = HasConfigService + & HasErrorReporter + & HasImportCiphersRepository + & HasStateService + + // MARK: Private Properties + + /// The coordinator that handles navigation. + private let coordinator: AnyCoordinator + + /// The services used by this processor. + private let services: Services + + // MARK: Initialization + + /// Creates a new `ImportCXPProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - services: The services used by the processor. + /// - state: The initial state of the processor. + /// + init( + coordinator: AnyCoordinator, + services: Services, + state: ImportCXPState + ) { + self.coordinator = coordinator + self.services = services + super.init(state: state) + } + + // MARK: Methods + + override func perform(_ effect: ImportCXPEffect) async { + switch effect { + case .appeared: + await checkEnabled() + case .cancel: + cancelWithConfirmation() + case .mainButtonTapped: + switch state.status { + case .failure, .start: + await startImport() + case .importing: + break + case .success: + coordinator.navigate(to: .dismiss) + } + } + } + + // MARK: Private + + /// Checks whether the CXP import feature is enabled. + private func checkEnabled() async { + guard #available(iOS 18.2, *), await services.configService.getFeatureFlag(.cxpImportMobile) else { + state.status = .failure(message: Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice) + return + } + } + + /// Starts the import process. + private func startImport() async { + #if compiler(>=6.0.3) + + guard #available(iOS 18.2, *), let credentialImportToken = state.credentialImportToken else { + coordinator.showAlert( + .defaultAlert( + title: Localizations.importError, + message: Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice + ) + ) + return + } + + state.status = .importing + + do { + let results = try await services.importCiphersRepository.importCiphers( + credentialImportToken: credentialImportToken, + onProgress: { progress in state.progress = progress } + ) + + state.status = .success( + totalImportedCredentials: results.map(\.count).reduce(0, +), + importedResults: results + ) + } catch ImportCiphersRepositoryError.noDataFound { + state.status = .failure(message: "No data found to import.") + } catch ImportCiphersRepositoryError.dataEncodingFailed { + state.status = .failure(message: "Import data encoding failed.") + } catch { + state.status = .failure(message: Localizations.thereWasAnIssueImportingAllOfYourPasswordsNoDataWasDeleted) + services.errorReporter.log(error: error) + } + + #endif + } + + /// Shows the alert confirming the user wants to import logins later. + private func cancelWithConfirmation() { + guard !state.isFeatureUnvailable else { + coordinator.navigate(to: .dismiss) + return + } + + coordinator.showAlert(.confirmCancelCXPImport { [weak self] in + guard let self else { return } + coordinator.navigate(to: .dismiss) + }) + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessorTests.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessorTests.swift new file mode 100644 index 000000000..fb7f88fb5 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPProcessorTests.swift @@ -0,0 +1,318 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCXPProcessorTests + +class ImportCXPProcessorTests: BitwardenTestCase { + // MARK: Properties + + var configService: MockConfigService! + var coordinator: MockCoordinator! + var errorReporter: MockErrorReporter! + var importCiphersRepository: MockImportCiphersRepository! + var state: ImportCXPState! + var stateService: MockStateService! + var subject: ImportCXPProcessor! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + configService = MockConfigService() + coordinator = MockCoordinator() + errorReporter = MockErrorReporter() + importCiphersRepository = MockImportCiphersRepository() + state = ImportCXPState() + stateService = MockStateService() + subject = ImportCXPProcessor( + coordinator: coordinator.asAnyCoordinator(), + services: ServiceContainer.withMocks( + configService: configService, + errorReporter: errorReporter, + importCiphersRepository: importCiphersRepository, + stateService: stateService + ), + state: state + ) + } + + override func tearDown() { + super.tearDown() + + configService = nil + coordinator = nil + errorReporter = nil + importCiphersRepository = nil + state = nil + stateService = nil + subject = nil + } + + // MARK: Tests + + /// `perform(_:)` with `.appeared` sets the status as `.failure` with a message + /// when the feature flag `.cxpImportMobile` is not enabled. + @MainActor + func test_perform_appearedNoFeatureFlag() async { + await subject.perform(.appeared) + guard case let .failure(message) = subject.state.status else { + XCTFail("Status should be failure") + return + } + XCTAssertEqual(message, Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice) + } + + /// `perform(_:)` with `.appeared` sets the status as `.failure` with a message + /// when the feature flag `.cxpImportMobile` is not enabled. + @MainActor + func test_perform_appearedFeatureFlagEnabled() async throws { + guard #available(iOS 18.2, *) else { + throw XCTSkip("CXP Import feature is not available on this device") + } + + configService.featureFlagsBool[.cxpImportMobile] = true + await subject.perform(.appeared) + if case .failure = subject.state.status { + XCTFail("Status shouldn't be failure when CXP import is enabled") + } + } + + /// `perform(_:)` with `.cancel` with feature available shows confirmation and navigates to dismiss. + @MainActor + func test_perform_cancel() async throws { + subject.state.isFeatureUnvailable = false + let task = Task { + await subject.perform(.cancel) + } + defer { task.cancel() } + + try await waitForAsync { [weak self] in + guard let self else { return true } + return !coordinator.alertShown.isEmpty + } + + let confirmCancelAlert = try XCTUnwrap(coordinator.alertShown.first) + try await confirmCancelAlert.tapAction(title: Localizations.yes) + + try await waitForAsync { [weak self] in + guard let self else { return true } + return !coordinator.routes.isEmpty + } + + XCTAssertEqual(.dismiss, coordinator.routes.last) + } + + /// `perform(_:)` with `.cancel` with feature available shows confirmation and + /// doesn't navigate to dismiss if the user cancels the confirmation dialog. + @MainActor + func test_perform_cancelNoConfirmation() async throws { + subject.state.isFeatureUnvailable = false + let task = Task { + await subject.perform(.cancel) + } + defer { task.cancel() } + + try await waitForAsync { [weak self] in + guard let self else { return true } + return !coordinator.alertShown.isEmpty + } + + let confirmCancelAlert = try XCTUnwrap(coordinator.alertShown.first) + try await confirmCancelAlert.tapAction(title: Localizations.no) + + XCTAssertTrue(coordinator.routes.isEmpty) + } + + /// `perform(_:)` with `.cancel` with feature unavailable navigates to dismiss. + @MainActor + func test_perform_cancelFeatureUnavailable() async throws { + subject.state.isFeatureUnvailable = true + let task = Task { + await subject.perform(.cancel) + } + defer { task.cancel() } + + try await waitForAsync { [weak self] in + guard let self else { return true } + return !coordinator.routes.isEmpty + } + + XCTAssertEqual(.dismiss, coordinator.routes.last) + } + + /// `perform(_:)` with `.mainButtonTapped` with `.start` status. + @MainActor + func test_perform_mainButtonTappedStart() async throws { + subject.state.status = .start + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + try await perform_mainButtonTapped_startImport() + } + + /// `perform(_:)` with `.mainButtonTapped` with `.failure` status. + @MainActor + func test_perform_mainButtonTappedFailure() async throws { + subject.state.status = .failure(message: "Error") + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + try await perform_mainButtonTapped_startImport() + } + + /// `perform(_:)` with `.mainButtonTapped` with `.success` status which dismisses the view. + @MainActor + func test_perform_mainButtonTappedSuccess() async throws { + subject.state.status = .success(totalImportedCredentials: 10, importedResults: []) + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + + await subject.perform(.mainButtonTapped) + + XCTAssertEqual(coordinator.routes.last, .dismiss) + } + + /// `perform(_:)` with `.mainButtonTapped` with `.start` status but no data found. + @MainActor + func test_perform_mainButtonTappedStartNoDataFound() async throws { + guard try checkCompiler() else { + return + } + + subject.state.status = .start + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + + importCiphersRepository.importCiphersResult.withVerification { _ in + self.subject.state.status == .importing + }.throwing(ImportCiphersRepositoryError.noDataFound) + + await subject.perform(.mainButtonTapped) + + guard checkAlertShownWhenNotInCorrectIOSVersion() else { + return + } + + guard case let .failure(message) = subject.state.status else { + XCTFail("Importing status is not failure.") + return + } + + XCTAssertEqual(message, "No data found to import.") + } + + /// `perform(_:)` with `.mainButtonTapped` with `.start` status but data encoding failed. + @MainActor + func test_perform_mainButtonTappedStartDataEncodingFailed() async throws { + guard try checkCompiler() else { + return + } + + subject.state.status = .start + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + + importCiphersRepository.importCiphersResult.withVerification { _ in + self.subject.state.status == .importing + }.throwing(ImportCiphersRepositoryError.dataEncodingFailed) + + await subject.perform(.mainButtonTapped) + + guard checkAlertShownWhenNotInCorrectIOSVersion() else { + return + } + + guard case let .failure(message) = subject.state.status else { + XCTFail("Importing status is not failure.") + return + } + + XCTAssertEqual(message, "Import data encoding failed.") + } + + /// `perform(_:)` with `.mainButtonTapped` with `.start` status but throws error. + @MainActor + func test_perform_mainButtonTappedStartThrowing() async throws { + guard try checkCompiler() else { + return + } + + subject.state.status = .start + subject.state.credentialImportToken = UUID(uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec") + + importCiphersRepository.importCiphersResult.withVerification { _ in + self.subject.state.status == .importing + }.throwing(BitwardenTestError.example) + + await subject.perform(.mainButtonTapped) + + guard checkAlertShownWhenNotInCorrectIOSVersion() else { + return + } + + guard case let .failure(message) = subject.state.status else { + XCTFail("Importing status is not failure.") + return + } + + XCTAssertEqual(message, Localizations.thereWasAnIssueImportingAllOfYourPasswordsNoDataWasDeleted) + XCTAssertEqual(errorReporter.errors as? [BitwardenTestError], [.example]) + } + + // MARK: Private + + /// Performs `.perform(.mainButtonTapped)` to start import and checks everything went good. + @MainActor + private func perform_mainButtonTapped_startImport() async throws { + guard try checkCompiler() else { + return + } + + let expectedResults = [ + ImportedCredentialsResult(count: 12, type: .password), + ImportedCredentialsResult(count: 7, type: .passkey), + ImportedCredentialsResult(count: 11, type: .card), + ] + importCiphersRepository.importCiphersResult.withVerification { _ in + self.subject.state.status == .importing + }.withResult(expectedResults) + + await subject.perform(.mainButtonTapped) + + guard checkAlertShownWhenNotInCorrectIOSVersion() else { + return + } + + guard case let .success(total, results) = subject.state.status else { + XCTFail("Importing status is not success.") + return + } + + XCTAssertEqual(total, 30) + XCTAssertEqual(results, expectedResults) + } + + /// Checks whether the appropriate compiler is being used to have the code available. + /// - Returns: `true` if the compiler is correct, `false`otherwise. + private func checkCompiler() throws -> Bool { + #if compiler(>=6.0.3) + return true + #else + throw XCTSkip("CXP Import works only from 6.0.3 compiler.") + #endif + } + + /// Checks whether the alert is shown when not in the correct iOS version for CXP Import to work. + @MainActor + private func checkAlertShownWhenNotInCorrectIOSVersion() -> Bool { + guard #available(iOS 18.2, *) else { + XCTAssertEqual( + coordinator.alertShown, + [ + .defaultAlert( + title: Localizations.importError, + message: Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice + ), + ] + ) + return false + } + + return true + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPState.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPState.swift new file mode 100644 index 000000000..b0a538e99 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPState.swift @@ -0,0 +1,107 @@ +import Foundation + +// MARK: - ImportCXPState + +/// The state used to present the `ImportCXPView`. +/// +struct ImportCXPState: Equatable, Sendable { + // MARK: Types + + /// The status of the import process. + enum ImportCXPStatus: Equatable, Sendable { + /// The import flow is at the start point. + case start + + /// The import flow is in progress. + case importing + + /// The import flow succeded. + case success(totalImportedCredentials: Int, importedResults: [ImportedCredentialsResult]) + + /// The import flow failed. + case failure(message: String) + } + + // MARK: Properties + + /// The token used in `ASCredentialImportManager` to get the credentials to import. + var credentialImportToken: UUID? + + /// Whether the CXP import feature is available. + var isFeatureUnvailable: Bool = false + + /// The title of the main button. + var mainButtonTitle: String { + return switch status { + case .start: + Localizations.continue + case .importing: + "" + case .success: + Localizations.showVault + case .failure: + Localizations.retryImport + } + } + + /// The main icon to be displayed. + var mainIcon: ImageAsset { + return switch status { + case .importing, .start: + Asset.Images.fileUpload24 + case .success: + Asset.Images.checkCircle24 + case .failure: + Asset.Images.circleX16 + } + } + + /// The message to display on the page header. + var message: String { + return switch status { + case .start: + Localizations.startImportCXPDescriptionLong + case .importing: + "" + case let .success(total, _): + Localizations.itemsSuccessfullyImported(total) + case let .failure(message): + message + } + } + + /// The progress of importing credentials. + var progress = 0.0 + + /// The title to display on the page header. + var title: String { + return switch status { + case .start: + Localizations.importPasswords + case .importing: + Localizations.importingEllipsis + case .success: + Localizations.importSuccessful + case .failure: + isFeatureUnvailable ? Localizations.importNotAvailable : Localizations.importFailed + } + } + + /// Whether to show the cancel button. + var showCancelButton: Bool { + return switch status { + case .importing, .success: + false + case .failure, .start: + true + } + } + + /// Whether to show the main button. + var showMainButton: Bool { + status != .importing || isFeatureUnvailable + } + + /// The current status of the import process. + var status: ImportCXPStatus = .start +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPStateTests.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPStateTests.swift new file mode 100644 index 000000000..47a342ba9 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPStateTests.swift @@ -0,0 +1,120 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCXPStateTests + +class ImportCXPStateTests: BitwardenTestCase { + // MARK: Properties + + var subject: ImportCXPState! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + subject = ImportCXPState() + } + + override func tearDown() { + super.tearDown() + + subject = nil + } + + // MARK: Tests + + /// `getter:mainButtonTitle` returns the appropriate value depending on the `status`. + func test_mainButtonTitle() { + subject.status = .start + XCTAssertEqual(subject.mainButtonTitle, Localizations.continue) + + subject.status = .importing + XCTAssertEqual(subject.mainButtonTitle, "") + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertEqual(subject.mainButtonTitle, Localizations.showVault) + + subject.status = .failure(message: "") + XCTAssertEqual(subject.mainButtonTitle, Localizations.retryImport) + } + + /// `getter:mainIcon` returns the appropriate value depending on the `status`. + func test_mainIcon() { + subject.status = .start + XCTAssertEqual(subject.mainIcon.name, Asset.Images.fileUpload24.name) + + subject.status = .importing + XCTAssertEqual(subject.mainIcon.name, Asset.Images.fileUpload24.name) + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertEqual(subject.mainIcon.name, Asset.Images.checkCircle24.name) + + subject.status = .failure(message: "") + XCTAssertEqual(subject.mainIcon.name, Asset.Images.circleX16.name) + } + + /// `getter:message` returns the appropriate value depending on the `status`. + func test_message() { + subject.status = .start + XCTAssertEqual(subject.message, Localizations.startImportCXPDescriptionLong) + + subject.status = .importing + XCTAssertEqual(subject.message, "") + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertEqual(subject.message, Localizations.itemsSuccessfullyImported(1)) + + subject.status = .failure(message: "Something went wrong") + XCTAssertEqual(subject.message, "Something went wrong") + } + + /// `getter:title` returns the appropriate value depending on the `status`. + func test_title() { + subject.status = .start + XCTAssertEqual(subject.title, Localizations.importPasswords) + + subject.status = .importing + XCTAssertEqual(subject.title, Localizations.importingEllipsis) + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertEqual(subject.title, Localizations.importSuccessful) + + subject.status = .failure(message: "Something went wrong") + XCTAssertEqual(subject.title, Localizations.importFailed) + + subject.isFeatureUnvailable = true + XCTAssertEqual(subject.title, Localizations.importNotAvailable) + } + + /// `getter:showCancelButton` returns the appropriate value depending on the `status`. + func test_showCancelButton() { + subject.status = .start + XCTAssertTrue(subject.showCancelButton) + + subject.status = .importing + XCTAssertFalse(subject.showCancelButton) + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertFalse(subject.showCancelButton) + + subject.status = .failure(message: "Something went wrong") + XCTAssertTrue(subject.showCancelButton) + } + + /// `getter:showMainButton` returns the appropriate value depending on the `status`. + func test_showMainButton() { + subject.status = .start + XCTAssertTrue(subject.showMainButton) + + subject.status = .importing + XCTAssertFalse(subject.showMainButton) + + subject.status = .success(totalImportedCredentials: 1, importedResults: []) + XCTAssertTrue(subject.showMainButton) + + subject.status = .failure(message: "Something went wrong") + XCTAssertTrue(subject.showMainButton) + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPView.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPView.swift new file mode 100644 index 000000000..711332c31 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +// MARK: - ImportCXPView + +/// A view to import credentials in the Credential Exchange protocol flow. +/// +struct ImportCXPView: View { + // MARK: Properties + + /// The `Store` for this view. + @ObservedObject var store: Store + + // MARK: View + + var body: some View { + Group { + VStack(spacing: 16) { + PageHeaderView( + image: Image(decorative: store.state.mainIcon), + title: store.state.title, + message: store.state.message, + style: .largeWithTintedIcon + ) + switch store.state.status { + case .start: + EmptyView() + case .importing: + ProgressView(value: store.state.progress) + .tint(Asset.Colors.tintPrimary.swiftUIColor) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: 3, anchor: .center) + .accessibilityIdentifier("ImportProgress") + case let .success(_, results): + VStack(spacing: 16) { + ForEach(results) { result in + importedTypeRow(result: result) + } + } + .padding(.horizontal, 20) + .padding(.top, 12) + case .failure: + EmptyView() + } + } + .padding(.top, 8) + .frame(maxWidth: .infinity) + .scrollView(backgroundColor: Asset.Colors.backgroundSecondary.swiftUIColor) + .safeAreaInset(edge: .bottom) { + VStack { + if store.state.showMainButton { + AsyncButton(store.state.mainButtonTitle) { + await store.perform(.mainButtonTapped) + } + .buttonStyle(.primary()) + .accessibilityIdentifier("MainButton") + } + + if store.state.showCancelButton { + AsyncButton(Localizations.cancel) { + await store.perform(.cancel) + } + .buttonStyle(.secondary()) + .accessibilityIdentifier("CancelButton") + } + } + .padding(.horizontal, 16) + .background(Asset.Colors.backgroundSecondary.swiftUIColor) + } + } + .transition(.opacity) + .animation(.easeInOut, value: store.state.status) + .task { + await store.perform(.appeared) + } + .apply { view in + if #available(iOSApplicationExtension 16.0, *) { + view.toolbar(.hidden) + } else { + view.navigationBarHidden(true) + } + } + } + + // MARK: Private + + /// The row for an imported type result. + @ViewBuilder + private func importedTypeRow(result: ImportedCredentialsResult) -> some View { + HStack { + Text(result.localizedTypePlural) + .styleGuide(.body) + Spacer() + Text("\(result.count)") + .styleGuide(.body) + .accessibilityIdentifier("\(result.type)ImportTotal") + } + } +} + +// MARK: - Previews + +#if DEBUG +#Preview("Start") { + ImportCXPView(store: Store(processor: StateProcessor(state: ImportCXPState()))) + .navStackWrapped +} + +#Preview("Importing") { + ImportCXPView( + store: Store( + processor: StateProcessor( + state: ImportCXPState( + progress: 0.3, + status: .importing + ) + ) + ) + ).navStackWrapped +} + +#Preview("Success") { + ImportCXPView( + store: Store( + processor: StateProcessor( + state: ImportCXPState( + status: .success( + totalImportedCredentials: 30, + importedResults: [ + ImportedCredentialsResult(count: 13, type: .password), + ImportedCredentialsResult(count: 7, type: .passkey), + ImportedCredentialsResult(count: 10, type: .card), + ] + ) + ) + ) + ) + ).navStackWrapped +} + +#Preview("Failure") { + ImportCXPView( + store: Store( + processor: StateProcessor( + state: ImportCXPState( + status: .failure( + message: "Something went wrong" + ) + ) + ) + ) + ).navStackWrapped +} + +#endif diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPViewTests.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPViewTests.swift new file mode 100644 index 000000000..9e8398ec1 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/ImportCXPViewTests.swift @@ -0,0 +1,99 @@ +import SnapshotTesting +import ViewInspector +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCXPViewTests + +class ImportCXPViewTests: BitwardenTestCase { + // MARK: Properties + + var processor: MockProcessor! + var subject: ImportCXPView! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + processor = MockProcessor(state: ImportCXPState()) + let store = Store(processor: processor) + + subject = ImportCXPView(store: store) + } + + override func tearDown() { + super.tearDown() + + processor = nil + subject = nil + } + + // MARK: Tests + + /// Tapping on the continue button performs the `.mainButtonTapped` effect. + @MainActor + func test_mainButton_tapped() throws { + processor.state.status = .start + let button = try subject.inspect().find(button: Localizations.continue) + try button.tap() + waitFor { + processor.effects.last == .mainButtonTapped + } + } + + /// Tapping on the cancel button performs the `.cancel` effect. + @MainActor + func test_cancelButton_tapped() throws { + processor.state.status = .start + let button = try subject.inspect().find(button: Localizations.cancel) + try button.tap() + waitFor { + processor.effects.last == .cancel + } + } + + /// Test a snapshot on start status. + func test_snapshot_start() { + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } + + /// Test a snapshot on importing status. + @MainActor + func test_snapshot_importing() { + processor.state.progress = 0.3 + processor.state.status = .importing + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } + + /// Test a snapshot on success status. + @MainActor + func test_snapshot_success() { + processor.state.status = .success(totalImportedCredentials: 10, importedResults: [ + ImportedCredentialsResult(count: 13, type: .password), + ImportedCredentialsResult(count: 7, type: .passkey), + ImportedCredentialsResult(count: 10, type: .card), + ]) + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } + + /// Test a snapshot on failure status. + @MainActor + func test_snapshot_failure() { + processor.state.status = .failure(message: "Something went wrong") + assertSnapshots( + of: subject, + as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5] + ) + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.1.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.1.png new file mode 100644 index 000000000..262d6c586 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.1.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.2.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.2.png new file mode 100644 index 000000000..9b326e50d Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.2.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.3.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.3.png new file mode 100644 index 000000000..a992432d3 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_failure.3.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.1.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.1.png new file mode 100644 index 000000000..3268e0a58 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.1.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.2.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.2.png new file mode 100644 index 000000000..a61cff9ed Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.2.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.3.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.3.png new file mode 100644 index 000000000..ae64a804d Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_importing.3.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.1.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.1.png new file mode 100644 index 000000000..336ff4ac2 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.1.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.2.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.2.png new file mode 100644 index 000000000..e00977966 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.2.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.3.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.3.png new file mode 100644 index 000000000..8327db2f6 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_start.3.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.1.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.1.png new file mode 100644 index 000000000..d041cf78b Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.1.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.2.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.2.png new file mode 100644 index 000000000..14f5cb60c Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.2.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.3.png b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.3.png new file mode 100644 index 000000000..c4b1fe803 Binary files /dev/null and b/BitwardenShared/UI/Tools/ImportCXP/ImportCXP/__Snapshots__/ImportCXPViewTests/test_snapshot_success.3.png differ diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinator.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinator.swift new file mode 100644 index 000000000..2b7443461 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinator.swift @@ -0,0 +1,68 @@ +import Foundation + +/// A coordinator that manages navigation for the Credential Exchange import flow. +/// +class ImportCXPCoordinator: Coordinator, HasStackNavigator { + // MARK: Types + + typealias Services = HasConfigService + & HasErrorReporter + & HasImportCiphersRepository + & HasStateService + + // MARK: Private Properties + + /// The services used by this coordinator. + private let services: Services + + // MARK: Properties + + /// The stack navigator that is managed by this coordinator. + private(set) weak var stackNavigator: StackNavigator? + + // MARK: Initialization + + /// Creates a new `ImportCoordinator`. + /// + /// - Parameters: + /// - services: The services used by this coordinator. + /// - stackNavigator: The stack navigator that is managed by this coordinator. + /// + init( + services: Services, + stackNavigator: StackNavigator + ) { + self.services = services + self.stackNavigator = stackNavigator + } + + // MARK: Methods + + func navigate( + to route: ImportCXPRoute, + context: AnyObject? + ) { + switch route { + case .dismiss: + stackNavigator?.dismiss() + case let .importCredentials(credentialImportToken): + showImportCXP(credentialImportToken: credentialImportToken) + } + } + + func start() {} + + // MARK: Private Methods + + /// Configures and displays the Credential Exchange import view. + private func showImportCXP(credentialImportToken: UUID) { + let processor = ImportCXPProcessor( + coordinator: asAnyCoordinator(), + services: services, + state: ImportCXPState(credentialImportToken: credentialImportToken) + ) + + let view = ImportCXPView(store: Store(processor: processor)) + stackNavigator?.replace(view) + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinatorTests.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinatorTests.swift new file mode 100644 index 000000000..a5100abd5 --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPCoordinatorTests.swift @@ -0,0 +1,85 @@ +import XCTest + +@testable import BitwardenShared + +// MARK: - ImportCXPCoordinatorTests + +class ImportCXPCoordinatorTests: BitwardenTestCase { + // MARK: Properties + + var configService: MockConfigService! + var errorReporter: MockErrorReporter! + var importCiphersRepository: MockImportCiphersRepository! + var stackNavigator: MockStackNavigator! + var stateService: MockStateService! + var subject: ImportCXPCoordinator! + + // MARK: Setup & Teardown + + override func setUp() { + super.setUp() + + configService = MockConfigService() + errorReporter = MockErrorReporter() + importCiphersRepository = MockImportCiphersRepository() + stackNavigator = MockStackNavigator() + stateService = MockStateService() + + subject = ImportCXPCoordinator( + services: ServiceContainer.withMocks( + configService: configService, + errorReporter: errorReporter, + importCiphersRepository: importCiphersRepository, + stateService: stateService + ), + stackNavigator: stackNavigator + ) + } + + override func tearDown() { + super.tearDown() + + configService = nil + errorReporter = nil + importCiphersRepository = nil + stackNavigator = nil + stateService = nil + subject = nil + } + + // MARK: Tests + + /// `navigate(to:context:)` with `.dismiss`, calls dismiss in the stack navigator. + @MainActor + func test_navigate_dismiss() { + subject.navigate(to: .dismiss) + XCTAssertTrue(stackNavigator.actions.contains(where: { action in + action.type == .dismissed + })) + } + + /// `navigate(to:context:)` with `.importCredentials`, shows the import processor. + @MainActor + func test_navigate_importCredentials() throws { + subject + .navigate( + to: .importCredentials( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )! + ) + ) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .replaced) + XCTAssertTrue(action.view is ImportCXPView) + } + + /// `start()` has no effect. + @MainActor + func test_start() { + subject.start() + + XCTAssertTrue(stackNavigator.actions.isEmpty) + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXPModule.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPModule.swift new file mode 100644 index 000000000..3257199dd --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPModule.swift @@ -0,0 +1,27 @@ +// MARK: - ImportCXPModule + +/// An object that builds coordinators for the Credential Exchange import flow. +/// +@MainActor +protocol ImportCXPModule { + /// Initializes a coordinator for navigating between `ImportCXPRoute`s. + /// + /// - Parameters: + /// - stackNavigator: The stack navigator that will be used to navigate between routes. + /// - Returns: A coordinator that can navigate to `ImportCXPRoute`s. + /// + func makeImportCXPCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator +} + +extension DefaultAppModule: ImportCXPModule { + func makeImportCXPCoordinator( + stackNavigator: StackNavigator + ) -> AnyCoordinator { + ImportCXPCoordinator( + services: services, + stackNavigator: stackNavigator + ).asAnyCoordinator() + } +} diff --git a/BitwardenShared/UI/Tools/ImportCXP/ImportCXPRoute.swift b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPRoute.swift new file mode 100644 index 000000000..a129e962d --- /dev/null +++ b/BitwardenShared/UI/Tools/ImportCXP/ImportCXPRoute.swift @@ -0,0 +1,13 @@ +import Foundation + +// MARK: - ImportCXPRoute + +/// A route to specific screens in the Credential Exhange import flow. +public enum ImportCXPRoute: Equatable, Hashable { + /// A route to dismiss the screen currently presented modally. + case dismiss + + /// A route to begin importing using Credential Exchange protocol. + /// - Parameter: The `credentialImportToken` to use in the import manager. + case importCredentials(credentialImportToken: UUID) +} diff --git a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift index 9b2fab744..0486cfba3 100644 --- a/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift +++ b/BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift @@ -4,6 +4,20 @@ import UIKit // MARK: - Alert+Vault extension Alert { + /// Returns an alert confirming cancelling the CXP import process. + /// - Parameter action: The action to perform if the user confirms. + /// - Returns: An alert confirming cancelling the CXP import process. + static func confirmCancelCXPImport(action: @escaping () async -> Void) -> Alert { + Alert( + title: Localizations.cancel, + message: Localizations.areYouSureYouWantToCancelTheImportProcessQuestionMark, + alertActions: [ + AlertAction(title: Localizations.yes, style: .default) { _, _ in await action() }, + AlertAction(title: Localizations.no, style: .cancel), + ] + ) + } + /// Returns an alert confirming whether to clone an item without the FIDO2 credential. /// /// - Parameter action: The action to perform if the user confirms. diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift index 3480dcf51..10107cd06 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift @@ -58,6 +58,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { // MARK: Types typealias Module = GeneratorModule + & ImportCXPModule & ImportLoginsModule & VaultItemModule @@ -191,6 +192,8 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { stackNavigator?.dismiss() case let .group(group, filter): showGroup(group, filter: filter) + case let .importCXP(cxpRoute): + showImportCXP(route: cxpRoute) case .importLogins: showImportLogins() case .list: @@ -295,6 +298,21 @@ final class VaultCoordinator: Coordinator, HasStackNavigator { ) } + /// Shows the Credential Exchange import route (not in a tab). This is used when another app + /// exporting credentials with Credential Exchange protocol chooses our app as a provider to import credentials. + /// + /// - Parameter route: The `ImportCXPRoute` to show. + /// + private func showImportCXP(route: ImportCXPRoute) { + let navigationController = UINavigationController() + let coordinator = module.makeImportCXPCoordinator( + stackNavigator: navigationController + ) + coordinator.start() + coordinator.navigate(to: route) + stackNavigator?.present(navigationController) + } + /// Shows the import login items screen. /// private func showImportLogins() { diff --git a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift index 1bf43b573..f773ba856 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift @@ -203,6 +203,33 @@ class VaultCoordinatorTests: BitwardenTestCase { XCTAssertEqual(module.importLoginsCoordinator.routes.last, .importLogins(.vault)) } + /// `navigate(to:)` with `.importCXP` presents the import view for Credential Exchange onto the stack navigator. + @MainActor + func test_navigateTo_importCXP() throws { + subject.navigate( + to: .importCXP( + .importCredentials( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )! + ) + ) + ) + + let action = try XCTUnwrap(stackNavigator.actions.last) + XCTAssertEqual(action.type, .presented) + XCTAssertTrue(action.view is UINavigationController) + XCTAssertTrue(module.importCXPCoordinator.isStarted) + XCTAssertEqual( + module.importCXPCoordinator.routes.last, + .importCredentials( + credentialImportToken: UUID( + uuidString: "e8f3b381-aac2-4379-87fe-14fac61079ec" + )! + ) + ) + } + /// `navigate(to:)` with `.list` pushes the vault list view onto the stack navigator. @MainActor func test_navigateTo_list_withoutPresented() throws { diff --git a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift index 5fe49bfd5..0f1cff284 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultRoute.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultRoute.swift @@ -45,6 +45,9 @@ public enum VaultRoute: Equatable, Hashable { /// A route to the vault item list screen for the specified group. case group(_ group: VaultListGroup, filter: VaultFilterType) + /// A route to the Credential Exchange import flow with the CXP specific route as a parameter. + case importCXP(ImportCXPRoute) + /// A route to the import logins screen. case importLogins diff --git a/GlobalTestHelpers/MockAppModule.swift b/GlobalTestHelpers/MockAppModule.swift index f6f75dd86..4b1eda038 100644 --- a/GlobalTestHelpers/MockAppModule.swift +++ b/GlobalTestHelpers/MockAppModule.swift @@ -9,6 +9,7 @@ class MockAppModule: ExtensionSetupModule, FileSelectionModule, GeneratorModule, + ImportCXPModule, ImportLoginsModule, LoginRequestModule, PasswordAutoFillModule, @@ -27,6 +28,7 @@ class MockAppModule: var fileSelectionDelegate: FileSelectionDelegate? var fileSelectionCoordinator = MockCoordinator() var generatorCoordinator = MockCoordinator() + var importCXPCoordinator = MockCoordinator() var importLoginsCoordinator = MockCoordinator() var loginRequestCoordinator = MockCoordinator() var passwordAutoFillCoordinator = MockCoordinator() @@ -88,6 +90,12 @@ class MockAppModule: generatorCoordinator.asAnyCoordinator() } + func makeImportCXPCoordinator( + stackNavigator: any StackNavigator + ) -> AnyCoordinator { + importCXPCoordinator.asAnyCoordinator() + } + func makeImportLoginsCoordinator( delegate: any ImportLoginsCoordinatorDelegate, stackNavigator: any StackNavigator diff --git a/project.yml b/project.yml index 479901aaa..8e55f696e 100644 --- a/project.yml +++ b/project.yml @@ -24,7 +24,7 @@ include: packages: BitwardenSdk: url: https://github.com/bitwarden/sdk-swift - revision: 72b179fcaf4d3f7a0e780df3b75ba3ebdfaa3773 + revision: 02332b257970cde14e48a8708e12e722f4a236d9 branch: unstable Firebase: url: https://github.com/firebase/firebase-ios-sdk