[PM-22549] Update CXP related code to work with new iOS 26 beta API (#1656)

This commit is contained in:
Federico Maccaroni 2025-08-07 16:35:09 -03:00 committed by GitHub
parent cfc401f6e0
commit 64bbab85db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 181 additions and 205 deletions

View File

@ -77,7 +77,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
#if SUPPORTS_CXP #if SUPPORTS_CXP
if #available(iOS 18.2, *), if #available(iOS 26.0, *),
let userActivity = connectionOptions.userActivities.first { let userActivity = connectionOptions.userActivities.first {
await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity) await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity)
} }
@ -101,7 +101,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
#if SUPPORTS_CXP #if SUPPORTS_CXP
if #available(iOS 18.2, *) { if #available(iOS 26.0, *) {
Task { Task {
await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity) await checkAndHandleCredentialExchangeActivity(appProcessor: appProcessor, userActivity: userActivity)
} }
@ -182,11 +182,11 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
#endif #endif
} }
// MARK: - SceneDelegate 18.2 // MARK: - SceneDelegate 26.0
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension SceneDelegate { extension SceneDelegate {
/// Checks whether there is an `ASCredentialExchangeActivity` in the `userActivity` and handles it. /// Checks whether there is an `ASCredentialExchangeActivity` in the `userActivity` and handles it.
/// - Parameters: /// - Parameters:

View File

@ -59,8 +59,7 @@ public extension JSONDecoder {
let decoder = JSONDecoder() let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .custom { keys in decoder.keyDecodingStrategy = .custom { keys in
let key = keys.last!.stringValue let key = keys.last!.stringValue
let camelCaseKey = keyToCamelCase(key: key) return AnyKey(stringValue: keyToCamelCase(key: key))
return AnyKey(stringValue: customTransformCodingKeyForCXF(key: camelCaseKey))
} }
decoder.dateDecodingStrategy = .secondsSince1970 decoder.dateDecodingStrategy = .secondsSince1970
return decoder return decoder
@ -82,23 +81,4 @@ public extension JSONDecoder {
// Handle PascalCase or camelCase. // Handle PascalCase or camelCase.
return key.prefix(1).lowercased() + key.dropFirst() return key.prefix(1).lowercased() + key.dropFirst()
} }
// MARK: Private Static Functions
/// Transforms the keys from Credential Exchange format handled by the Bitwarden SDK
/// into the keys that Apple expects.
private static func customTransformCodingKeyForCXF(key: String) -> String {
guard #available(iOS 18.3, *) else {
return switch key {
case "credentialId":
"credentialID"
case "rpId":
"rpID"
default:
key
}
}
return key
}
} }

View File

@ -8,10 +8,6 @@ class JSONDecoderBitwardenTests: BitwardenTestCase {
/// `JSONDecoder.cxfDecoder` can decode Credential Exchange Format. /// `JSONDecoder.cxfDecoder` can decode Credential Exchange Format.
func test_cxfDecoder_decodesISO8601DateWithFractionalSeconds() throws { func test_cxfDecoder_decodesISO8601DateWithFractionalSeconds() throws {
guard #available(iOS 18.3, *) else {
throw XCTSkip("This test only works from iOS 18.3")
}
let subject = JSONDecoder.cxfDecoder let subject = JSONDecoder.cxfDecoder
let toDecode = #"{"credentialId":"credential","date":1697790414,"otherKey":"other","rpId":"rp"}"# let toDecode = #"{"credentialId":"credential","date":1697790414,"otherKey":"other","rpId":"rp"}"#
@ -36,36 +32,6 @@ class JSONDecoderBitwardenTests: BitwardenTestCase {
) )
} }
/// `JSONDecoder.cxfDecoder` can decode Credential Exchange Format on iOS versions lower than iOS 18.3.
func test_cxfDecoder_decodesISO8601DateWithFractionalSecondsOlderiOS18dot3() throws {
if #available(iOS 18.3, *) {
throw XCTSkip("This test only works until iOS 18.3")
}
let subject = JSONDecoder.cxfDecoder
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. /// `JSONDecoder.defaultDecoder` can decode ISO8601 dates with fractional seconds.
func test_defaultDecoder_decodesISO8601DateWithFractionalSeconds() { func test_defaultDecoder_decodesISO8601DateWithFractionalSeconds() {
let subject = JSONDecoder.defaultDecoder let subject = JSONDecoder.defaultDecoder

View File

@ -23,25 +23,6 @@ public extension JSONEncoder {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode(Int(date.timeIntervalSince1970)) try container.encode(Int(date.timeIntervalSince1970))
} }
jsonEncoder.keyEncodingStrategy = .custom { keys in
let key = keys.last!.stringValue
return AnyKey(stringValue: customTransformCodingKeyForCXF(key: key))
}
return jsonEncoder return jsonEncoder
}() }()
// MARK: Static Functions
/// Transforms the keys from Credential Exchange format handled by the Bitwarden SDK
/// into the keys that Apple expects.
static func customTransformCodingKeyForCXF(key: String) -> String {
return switch key {
case "credentialID":
"credentialId"
case "rpID":
"rpId"
default:
key
}
}
} }

View File

@ -12,17 +12,17 @@ class JSONEncoderBitwardenTests: BitwardenTestCase {
subject.outputFormatting = .sortedKeys // added for test consistency so output is ordered. subject.outputFormatting = .sortedKeys // added for test consistency so output is ordered.
struct JSONBody: Codable { struct JSONBody: Codable {
let credentialID: String let credentialId: String
let date: Date let date: Date
let otherKey: String let otherKey: String
let rpID: String let rpId: String
} }
let body = JSONBody( let body = JSONBody(
credentialID: "credential", credentialId: "credential",
date: Date(year: 2023, month: 10, day: 20, hour: 8, minute: 26, second: 54), date: Date(year: 2023, month: 10, day: 20, hour: 8, minute: 26, second: 54),
otherKey: "other", otherKey: "other",
rpID: "rp" rpId: "rp"
) )
let encodedData = try subject.encode(body) let encodedData = try subject.encode(body)
let encodedString = String(data: encodedData, encoding: .utf8) let encodedString = String(data: encodedData, encoding: .utf8)

View File

@ -15,8 +15,8 @@ protocol ExportCXFCiphersRepository {
/// Export the credentials using the Credential Exchange flow. /// Export the credentials using the Credential Exchange flow.
/// ///
/// - Parameter data: Data to export. /// - Parameter data: Data to export.
@available(iOS 18.2, *) @available(iOS 26.0, *)
func exportCredentials(data: ASImportableAccount, presentationAnchor: () -> ASPresentationAnchor) async throws func exportCredentials(data: ASImportableAccount, presentationAnchor: () async -> ASPresentationAnchor) async throws
#endif #endif
/// Gets all ciphers to export in Credential Exchange flow. /// Gets all ciphers to export in Credential Exchange flow.
@ -28,7 +28,7 @@ protocol ExportCXFCiphersRepository {
/// Exports the vault creating the `ASImportableAccount` to be used in Credential Exchange Protocol. /// Exports the vault creating the `ASImportableAccount` to be used in Credential Exchange Protocol.
/// ///
/// - Returns: An `ASImportableAccount` /// - Returns: An `ASImportableAccount`
@available(iOS 18.2, *) @available(iOS 26.0, *)
func getExportVaultDataForCXF() async throws -> ASImportableAccount func getExportVaultDataForCXF() async throws -> ASImportableAccount
#endif #endif
} }
@ -93,10 +93,30 @@ class DefaultExportCXFCiphersRepository: ExportCXFCiphersRepository {
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
func exportCredentials(data: ASImportableAccount, presentationAnchor: () -> ASPresentationAnchor) async throws { func exportCredentials(
try await credentialManagerFactory.createExportManager(presentationAnchor: presentationAnchor()) data: ASImportableAccount,
.exportCredentials(ASExportedCredentialData(accounts: [data])) presentationAnchor: () async -> ASPresentationAnchor
) async throws {
let manager = await credentialManagerFactory.createExportManager(presentationAnchor: presentationAnchor())
let options = try await manager.requestExport(forExtensionBundleIdentifier: nil)
guard let exportOptions = options as? ASCredentialExportManager.ExportOptions else {
throw BitwardenError.generalError(
type: "Wrong export options",
message: "The credential manager returned wrong export options type."
)
}
try await manager.exportCredentials(
ASExportedCredentialData(
accounts: [data],
formatVersion: exportOptions.formatVersion,
exporterRelyingPartyIdentifier: Bundle.main.appIdentifier,
exporterDisplayName: "Bitwarden",
timestamp: Date.now
)
)
} }
#endif #endif
@ -108,7 +128,7 @@ class DefaultExportCXFCiphersRepository: ExportCXFCiphersRepository {
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
func getExportVaultDataForCXF() async throws -> ASImportableAccount { func getExportVaultDataForCXF() async throws -> ASImportableAccount {
let ciphers = try await getAllCiphersToExportCXF() let ciphers = try await getAllCiphersToExportCXF()

View File

@ -77,8 +77,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `exportCredentials(data:presentationAnchor:)` exports the credential data. /// `exportCredentials(data:presentationAnchor:)` exports the credential data.
@MainActor @MainActor
func test_exportCredentials() async throws { func test_exportCredentials() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Exporting ciphers requires iOS 18.2") throw XCTSkip("Exporting ciphers requires iOS 26.0")
} }
let exportManager = MockCredentialExportManager() let exportManager = MockCredentialExportManager()
credentialManagerFactory.exportManager = exportManager credentialManagerFactory.exportManager = exportManager
@ -90,8 +90,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `exportCredentials(data:presentationAnchor:)` throws when exporting. /// `exportCredentials(data:presentationAnchor:)` throws when exporting.
@MainActor @MainActor
func test_exportCredentials_throws() async throws { func test_exportCredentials_throws() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Exporting ciphers requires iOS 18.2") throw XCTSkip("Exporting ciphers requires iOS 26.0")
} }
let exportManager = MockCredentialExportManager() let exportManager = MockCredentialExportManager()
exportManager.exportCredentialsError = BitwardenTestError.example exportManager.exportCredentialsError = BitwardenTestError.example
@ -131,8 +131,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `getExportVaultDataForCXF()` gets the vault data prepared for export on CXF. /// `getExportVaultDataForCXF()` gets the vault data prepared for export on CXF.
@MainActor @MainActor
func test_getExportVaultDataForCXF() async throws { // swiftlint:disable:this function_body_length func test_getExportVaultDataForCXF() async throws { // swiftlint:disable:this function_body_length
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
cipherService.fetchAllCiphersResult = .success([ cipherService.fetchAllCiphersResult = .success([
.fixture(id: "1"), .fixture(id: "1"),
@ -192,8 +192,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `getExportVaultDataForCXF()` throws when getting all ciphers to export. /// `getExportVaultDataForCXF()` throws when getting all ciphers to export.
@MainActor @MainActor
func test_getExportVaultDataForCXF_throwsGettingCiphers() async throws { func test_getExportVaultDataForCXF_throwsGettingCiphers() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
cipherService.fetchAllCiphersResult = .failure(BitwardenTestError.example) cipherService.fetchAllCiphersResult = .failure(BitwardenTestError.example)
@ -205,8 +205,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `getExportVaultDataForCXF()` throws when getting account. /// `getExportVaultDataForCXF()` throws when getting account.
@MainActor @MainActor
func test_getExportVaultDataForCXF_throwsAccount() async throws { func test_getExportVaultDataForCXF_throwsAccount() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
cipherService.fetchAllCiphersResult = .success([ cipherService.fetchAllCiphersResult = .success([
.fixture(id: "1"), .fixture(id: "1"),
@ -221,8 +221,8 @@ class ExportCXFCiphersRepositoryTests: BitwardenTestCase {
/// `getExportVaultDataForCXF()` throws when exporting using the SDK. /// `getExportVaultDataForCXF()` throws when exporting using the SDK.
@MainActor @MainActor
func test_getExportVaultDataForCXF_throwsExporting() async throws { func test_getExportVaultDataForCXF_throwsExporting() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
cipherService.fetchAllCiphersResult = .success([ cipherService.fetchAllCiphersResult = .success([
.fixture(id: "1"), .fixture(id: "1"),

View File

@ -11,7 +11,7 @@ protocol ImportCiphersRepository: AnyObject {
/// - onProgress: Closure to update progress. /// - onProgress: Closure to update progress.
/// - Returns: A dictionary containing the localized cipher type (key) and count (value) of that type /// - Returns: A dictionary containing the localized cipher type (key) and count (value) of that type
/// that was imported, e.g. ["Passwords": 3, "Cards": 2]. /// that was imported, e.g. ["Passwords": 3, "Cards": 2].
@available(iOS 18.2, *) @available(iOS 26.0, *)
func importCiphers( func importCiphers(
credentialImportToken: UUID, credentialImportToken: UUID,
onProgress: @MainActor (Double) -> Void onProgress: @MainActor (Double) -> Void
@ -69,7 +69,7 @@ class DefaultImportCiphersRepository {
// MARK: ImportCiphersRepository // MARK: ImportCiphersRepository
extension DefaultImportCiphersRepository: ImportCiphersRepository { extension DefaultImportCiphersRepository: ImportCiphersRepository {
@available(iOS 18.2, *) @available(iOS 26.0, *)
func importCiphers( func importCiphers(
credentialImportToken: UUID, credentialImportToken: UUID,
onProgress: @MainActor (Double) -> Void onProgress: @MainActor (Double) -> Void

View File

@ -53,18 +53,16 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
/// updates progress report and returns the credentials result with each type count. /// updates progress report and returns the credentials result with each type count.
@MainActor @MainActor
func test_importCiphers_success() async throws { func test_importCiphers_success() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Importing ciphers requires iOS 18.2") throw XCTSkip("Importing ciphers requires iOS 26.0")
} }
let credentialImportManager = MockCredentialImportManager() let credentialImportManager = MockCredentialImportManager()
credentialImportManager.importCredentialsResult = try .success(getASExportedCredentialDataAsJson( credentialImportManager.importCredentialsResult = try .success(getASExportedCredentialDataAsJson(
data: ASExportedCredentialData( accounts: [
accounts: [ .fixture(items: [.fixture()]),
.fixture(items: [.fixture()]), ]
]
)
)) ))
credentialManagerFactory.importManager = credentialImportManager credentialManagerFactory.importManager = credentialImportManager
@ -113,16 +111,14 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
/// when there are no accounts after importing credentials. /// when there are no accounts after importing credentials.
@MainActor @MainActor
func test_importCiphers_noDataFound() async throws { func test_importCiphers_noDataFound() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Importing ciphers requires iOS 18.2") throw XCTSkip("Importing ciphers requires iOS 26.0")
} }
let credentialImportManager = MockCredentialImportManager() let credentialImportManager = MockCredentialImportManager()
credentialImportManager.importCredentialsResult = credentialImportManager.importCredentialsResult =
try .success(getASExportedCredentialDataAsJson( try .success(getASExportedCredentialDataAsJson(
data: ASExportedCredentialData( accounts: []
accounts: []
)
)) ))
credentialManagerFactory.importManager = credentialImportManager credentialManagerFactory.importManager = credentialImportManager
@ -140,18 +136,16 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
/// the SDK to import the ciphers. /// the SDK to import the ciphers.
@MainActor @MainActor
func test_importCiphers_sdkThrows() async throws { func test_importCiphers_sdkThrows() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Importing ciphers requires iOS 18.2") throw XCTSkip("Importing ciphers requires iOS 26.0")
} }
let credentialImportManager = MockCredentialImportManager() let credentialImportManager = MockCredentialImportManager()
credentialImportManager.importCredentialsResult = credentialImportManager.importCredentialsResult =
try .success(getASExportedCredentialDataAsJson( try .success(getASExportedCredentialDataAsJson(
data: ASExportedCredentialData( accounts: [
accounts: [ .fixture(items: [.fixture()]),
.fixture(items: [.fixture()]), ]
]
)
)) ))
credentialManagerFactory.importManager = credentialImportManager credentialManagerFactory.importManager = credentialImportManager
@ -171,18 +165,16 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
/// to import the ciphers. /// to import the ciphers.
@MainActor @MainActor
func test_importCiphers_throwsWhenImportingCiphersAPI() async throws { func test_importCiphers_throwsWhenImportingCiphersAPI() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Importing ciphers requires iOS 18.2") throw XCTSkip("Importing ciphers requires iOS 26.0")
} }
let credentialImportManager = MockCredentialImportManager() let credentialImportManager = MockCredentialImportManager()
credentialImportManager.importCredentialsResult = credentialImportManager.importCredentialsResult =
try .success(getASExportedCredentialDataAsJson( try .success(getASExportedCredentialDataAsJson(
data: ASExportedCredentialData( accounts: [
accounts: [ .fixture(items: [.fixture()]),
.fixture(items: [.fixture()]), ]
]
)
)) ))
credentialManagerFactory.importManager = credentialImportManager credentialManagerFactory.importManager = credentialImportManager
@ -209,18 +201,16 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
/// importing the ciphers. /// importing the ciphers.
@MainActor @MainActor
func test_importCiphers_throwsWhenSyncing() async throws { func test_importCiphers_throwsWhenSyncing() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("Importing ciphers requires iOS 18.2") throw XCTSkip("Importing ciphers requires iOS 26.0")
} }
let credentialImportManager = MockCredentialImportManager() let credentialImportManager = MockCredentialImportManager()
credentialImportManager.importCredentialsResult = credentialImportManager.importCredentialsResult =
try .success(getASExportedCredentialDataAsJson( try .success(getASExportedCredentialDataAsJson(
data: ASExportedCredentialData( accounts: [
accounts: [ .fixture(items: [.fixture()]),
.fixture(items: [.fixture()]), ]
]
)
)) ))
credentialManagerFactory.importManager = credentialImportManager credentialManagerFactory.importManager = credentialImportManager
@ -245,8 +235,15 @@ class ImportCiphersRepositoryTests: BitwardenTestCase {
// MARK: Private // MARK: Private
@available(iOS 18.2, *) @available(iOS 26.0, *)
private func getASExportedCredentialDataAsJson(data: ASExportedCredentialData) throws -> String { private func getASExportedCredentialDataAsJson(accounts: [ASImportableAccount]) throws -> String {
let data = ASExportedCredentialData(
accounts: accounts,
formatVersion: .v1,
exporterRelyingPartyIdentifier: "com.bitwarden.test",
exporterDisplayName: "Bitwarden Test",
timestamp: .now
)
let credentialData = try JSONEncoder.cxfEncoder.encode(data) let credentialData = try JSONEncoder.cxfEncoder.encode(data)
guard let credentialDataJsonString = String(data: credentialData, encoding: .utf8) else { guard let credentialDataJsonString = String(data: credentialData, encoding: .utf8) else {
throw BitwardenError.dataError("Failed to encode ASExportedCredentialData") throw BitwardenError.dataError("Failed to encode ASExportedCredentialData")

View File

@ -21,8 +21,11 @@ class MockExportCXFCiphersRepository: ExportCXFCiphersRepository {
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
func exportCredentials(data: ASImportableAccount, presentationAnchor: () -> ASPresentationAnchor) async throws { func exportCredentials(
data: ASImportableAccount,
presentationAnchor: () async -> ASPresentationAnchor
) async throws {
exportCredentialsData = data exportCredentialsData = data
if let exportCredentialsError { if let exportCredentialsError {
throw exportCredentialsError throw exportCredentialsError
@ -37,7 +40,7 @@ class MockExportCXFCiphersRepository: ExportCXFCiphersRepository {
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
func getExportVaultDataForCXF() async throws -> ASImportableAccount { func getExportVaultDataForCXF() async throws -> ASImportableAccount {
guard let result = try getExportVaultDataForCXFResult.get() as? ASImportableAccount else { guard let result = try getExportVaultDataForCXFResult.get() as? ASImportableAccount else {
throw MockExportCXFCiphersRepositoryError.unableToCastToASImportableAccount throw MockExportCXFCiphersRepositoryError.unableToCastToASImportableAccount
@ -51,7 +54,7 @@ class MockExportCXFCiphersRepository: ExportCXFCiphersRepository {
protocol ImportableAccountProxy {} protocol ImportableAccountProxy {}
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension ASImportableAccount: ImportableAccountProxy {} extension ASImportableAccount: ImportableAccountProxy {}
#endif #endif

View File

@ -4,38 +4,50 @@ import Foundation
// MARK: - CredentialManagerFactory // MARK: - CredentialManagerFactory
protocol CredentialManagerFactory { protocol CredentialManagerFactory {
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createExportManager(presentationAnchor: ASPresentationAnchor) -> CredentialExportManager func createExportManager(presentationAnchor: ASPresentationAnchor) -> CredentialExportManager
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createImportManager() -> CredentialImportManager func createImportManager() -> CredentialImportManager
} }
// MARK: - DefaultCredentialManagerFactory // MARK: - DefaultCredentialManagerFactory
struct DefaultCredentialManagerFactory: CredentialManagerFactory { struct DefaultCredentialManagerFactory: CredentialManagerFactory {
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createExportManager(presentationAnchor: ASPresentationAnchor) -> any CredentialExportManager { func createExportManager(presentationAnchor: ASPresentationAnchor) -> any CredentialExportManager {
ASCredentialExportManager(presentationAnchor: presentationAnchor) ASCredentialExportManager(presentationAnchor: presentationAnchor)
} }
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createImportManager() -> any CredentialImportManager { func createImportManager() -> any CredentialImportManager {
ASCredentialImportManager() ASCredentialImportManager()
} }
} }
// MARK: - CredentialExportManagerExportOptions
protocol CredentialExportManagerExportOptions {}
// MARK: - ASCredentialExportManager.ExportOptions
@available(iOS 26.0, *)
extension ASCredentialExportManager.ExportOptions: CredentialExportManagerExportOptions {}
// MARK: - CredentialExportManager // MARK: - CredentialExportManager
protocol CredentialExportManager: AnyObject { protocol CredentialExportManager: AnyObject {
@available(iOS 18.2, *) @available(iOS 26.0, *)
func exportCredentials(_ credentialData: ASExportedCredentialData) async throws func exportCredentials(_ credentialData: ASExportedCredentialData) async throws
@available(iOS 26.0, *)
func requestExport(forExtensionBundleIdentifier: String?) async throws -> CredentialExportManagerExportOptions
} }
// MARK: - CredentialImportManager // MARK: - CredentialImportManager
protocol CredentialImportManager: AnyObject { protocol CredentialImportManager: AnyObject {
@available(iOS 18.2, *) @available(iOS 26.0, *)
func importCredentials(token: UUID) async throws -> ASExportedCredentialData func importCredentials(token: UUID) async throws -> ASExportedCredentialData
} }
@ -46,10 +58,14 @@ protocol CredentialImportManager: AnyObject {
#if SUPPORTS_CXP #if SUPPORTS_CXP
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension ASCredentialExportManager: CredentialExportManager {} extension ASCredentialExportManager: CredentialExportManager {
func requestExport(forExtensionBundleIdentifier: String?) async throws -> any CredentialExportManagerExportOptions {
try await requestExport(for: forExtensionBundleIdentifier)
}
}
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension ASCredentialImportManager: CredentialImportManager {} extension ASCredentialImportManager: CredentialImportManager {}
#else #else
@ -61,9 +77,16 @@ class ASCredentialImportManager: CredentialImportManager {
} }
class ASCredentialExportManager: CredentialExportManager { class ASCredentialExportManager: CredentialExportManager {
struct ExportOptions: CredentialExportManagerExportOptions {}
init(presentationAnchor: ASPresentationAnchor) {} init(presentationAnchor: ASPresentationAnchor) {}
func exportCredentials(_ credentialData: ASExportedCredentialData) async throws {} func exportCredentials(_ credentialData: ASExportedCredentialData) async throws {}
@available(iOS 26.0, *)
func requestExport(forExtensionBundleIdentifier: String?) async throws -> CredentialExportManagerExportOptions {
ASCredentialExportManager.ExportOptions()
}
} }
struct ASExportedCredentialData {} struct ASExportedCredentialData {}

View File

@ -31,8 +31,8 @@ class CredentialManagerFactoryTests: BitwardenTestCase {
/// `createExportManager(presentationAnchor:)` creates an instance of the credential export manager. /// `createExportManager(presentationAnchor:)` creates an instance of the credential export manager.
@MainActor @MainActor
func test_createExportManager() throws { func test_createExportManager() throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("iOS 18.2 is required to run this test.") throw XCTSkip("iOS 26.0 is required to run this test.")
} }
let manager = subject.createExportManager(presentationAnchor: UIWindow()) let manager = subject.createExportManager(presentationAnchor: UIWindow())
@ -41,8 +41,8 @@ class CredentialManagerFactoryTests: BitwardenTestCase {
/// `createImportManager()` creates an instance of the credential import manager. /// `createImportManager()` creates an instance of the credential import manager.
func test_createImportManager() throws { func test_createImportManager() throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("iOS 18.2 is required to run this test.") throw XCTSkip("iOS 26.0 is required to run this test.")
} }
let manager = subject.createImportManager() let manager = subject.createImportManager()

View File

@ -9,12 +9,12 @@ class MockCredentialManagerFactory: CredentialManagerFactory {
var exportManager: CredentialExportManager? var exportManager: CredentialExportManager?
var importManager: CredentialImportManager? var importManager: CredentialImportManager?
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createExportManager(presentationAnchor: ASPresentationAnchor) -> any CredentialExportManager { func createExportManager(presentationAnchor: ASPresentationAnchor) -> any CredentialExportManager {
exportManager ?? MockCredentialExportManager() exportManager ?? MockCredentialExportManager()
} }
@available(iOS 18.2, *) @available(iOS 26.0, *)
func createImportManager() -> CredentialImportManager { func createImportManager() -> CredentialImportManager {
importManager ?? MockCredentialImportManager() importManager ?? MockCredentialImportManager()
} }
@ -24,13 +24,16 @@ class MockCredentialExportManager: CredentialExportManager {
var exportCredentialsCalled = false var exportCredentialsCalled = false
/// The data passed as parameter in `exportCredentials(_:)`. /// The data passed as parameter in `exportCredentials(_:)`.
/// A JSON encoded `String` is used as the value instead of the actual object /// A JSON encoded `String` is used as the value instead of the actual object
/// to avoid crashing on simulators older than iOS 18.2 because of not finding the symbol /// to avoid crashing on simulators older than iOS 26.0 because of not finding the symbol
/// thus resulting in bad access error when running the test suite. /// thus resulting in bad access error when running the test suite.
/// Use `JSONDecoder.cxfDecoder.decode` to decode data for this. /// Use `JSONDecoder.cxfDecoder.decode` to decode data for this.
var exportCredentialsJSONData: String? var exportCredentialsJSONData: String?
var exportCredentialsError: Error? var exportCredentialsError: Error?
var requestExportResult: Result<CredentialExportManagerExportOptions, Error> = .success(
MockCredentialExportManagerExportOptions()
)
@available(iOS 18.2, *) @available(iOS 26.0, *)
func exportCredentials(_ credentialData: ASExportedCredentialData) async throws { func exportCredentials(_ credentialData: ASExportedCredentialData) async throws {
exportCredentialsCalled = true exportCredentialsCalled = true
@ -45,17 +48,22 @@ class MockCredentialExportManager: CredentialExportManager {
throw exportCredentialsError throw exportCredentialsError
} }
} }
@available(iOS 26.0, *)
func requestExport(forExtensionBundleIdentifier: String?) async throws -> CredentialExportManagerExportOptions {
try requestExportResult.get()
}
} }
class MockCredentialImportManager: CredentialImportManager { class MockCredentialImportManager: CredentialImportManager {
/// The result of calling `importCredentials(token:)`. /// The result of calling `importCredentials(token:)`.
/// A JSON encoded `String` is used as the value instead of the actual object /// A JSON encoded `String` is used as the value instead of the actual object
/// to avoid crashing on simulators older than iOS 18.2 because of not finding the symbol /// to avoid crashing on simulators older than iOS 26.0 because of not finding the symbol
/// thus resulting in bad access error when running the test suite. /// thus resulting in bad access error when running the test suite.
/// Use `JSONEncoder.cxfEncoder.encode` to encode data for this. /// Use `JSONEncoder.cxfEncoder.encode` to encode data for this.
var importCredentialsResult: Result<String, Error> = .failure(BitwardenTestError.example) var importCredentialsResult: Result<String, Error> = .failure(BitwardenTestError.example)
@available(iOS 18.2, *) @available(iOS 26.0, *)
func importCredentials(token: UUID) async throws -> ASExportedCredentialData { func importCredentials(token: UUID) async throws -> ASExportedCredentialData {
guard let data = try importCredentialsResult.get().data(using: .utf8) else { guard let data = try importCredentialsResult.get().data(using: .utf8) else {
throw BitwardenError.dataError("importCredentialsResult data not set or not in UTF8") throw BitwardenError.dataError("importCredentialsResult data not set or not in UTF8")
@ -66,4 +74,7 @@ class MockCredentialImportManager: CredentialImportManager {
) )
} }
} }
struct MockCredentialExportManagerExportOptions: CredentialExportManagerExportOptions {}
#endif #endif

View File

@ -2,7 +2,7 @@
import AuthenticationServices import AuthenticationServices
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension ASImportableAccount { extension ASImportableAccount {
// MARK: Static methods // MARK: Static methods
@ -29,7 +29,7 @@ extension ASImportableAccount {
/// Dumps the content of the `ASImportableAccount` into lines which can be used with /// Dumps the content of the `ASImportableAccount` into lines which can be used with
/// inline snapshot assertion. /// inline snapshot assertion.
func dump() -> String { // swiftlint:disable:this cyclomatic_complexity function_body_length func dump() -> String { // swiftlint:disable:this function_body_length
var dumpResult = "" var dumpResult = ""
dumpResult.append("Email: \(email)\n") dumpResult.append("Email: \(email)\n")
dumpResult.append("UserName: \(userName)\n") dumpResult.append("UserName: \(userName)\n")
@ -37,15 +37,14 @@ extension ASImportableAccount {
let itemsResult = items.reduce(into: "") { result, item in let itemsResult = items.reduce(into: "") { result, item in
result.appendWithIndentation("Title: \(item.title)\n") result.appendWithIndentation("Title: \(item.title)\n")
result.appendWithIndentation("Type: \(item.type)\n") result.appendWithIndentation("Creation: \(String(describing: item.created))\n")
result.appendWithIndentation("Creation: \(item.created)\n") result.appendWithIndentation("Modified: \(String(describing: item.lastModified))\n")
result.appendWithIndentation("Modified: \(item.lastModified)\n")
result.appendWithIndentation("--- Credentials ---\n") result.appendWithIndentation("--- Credentials ---\n")
let credentialsResult = item.credentials.reduce(into: "") { credResult, credential in let credentialsResult = item.credentials.reduce(into: "") { credResult, credential in
switch credential { switch credential {
case let .basicAuthentication(basicAuthentication): case let .basicAuthentication(basicAuthentication):
if let username = basicAuthentication.username { if let username = basicAuthentication.userName {
credResult.appendWithIndentation("Username.FieldType: \(username.fieldType)\n", level: 2) credResult.appendWithIndentation("Username.FieldType: \(username.fieldType)\n", level: 2)
credResult.appendWithIndentation("Username.Value: \(username.value)\n", level: 2) credResult.appendWithIndentation("Username.Value: \(username.value)\n", level: 2)
} }
@ -53,16 +52,6 @@ extension ASImportableAccount {
credResult.appendWithIndentation("Password.FieldType: \(password.fieldType)\n", level: 2) credResult.appendWithIndentation("Password.FieldType: \(password.fieldType)\n", level: 2)
credResult.appendWithIndentation("Password.Value: \(password.value)\n", level: 2) credResult.appendWithIndentation("Password.Value: \(password.value)\n", level: 2)
} }
if !basicAuthentication.urls.isEmpty {
credResult.appendWithIndentation("--- Urls ---\n", level: 2)
let urlsResult = basicAuthentication.urls.reduce(into: "") { urlResult, url in
urlResult.appendWithIndentation(url, level: 3)
if url != basicAuthentication.urls.last {
urlResult.appendWithIndentation("\n\n", level: 3)
}
}
credResult.appendWithIndentation(urlsResult, level: 2)
}
case let .passkey(passkey): case let .passkey(passkey):
credResult.appendWithIndentation("CredentialID: \(passkey.credentialID)\n", level: 2) credResult.appendWithIndentation("CredentialID: \(passkey.credentialID)\n", level: 2)
credResult.appendWithIndentation("Key: \(passkey.key)\n", level: 2) credResult.appendWithIndentation("Key: \(passkey.key)\n", level: 2)
@ -80,12 +69,12 @@ extension ASImportableAccount {
} }
credResult.appendWithIndentation("Period: \(totp.period)\n", level: 2) credResult.appendWithIndentation("Period: \(totp.period)\n", level: 2)
credResult.appendWithIndentation("Secret: \(totp.secret)\n", level: 2) credResult.appendWithIndentation("Secret: \(totp.secret)\n", level: 2)
credResult.appendWithIndentation("Username: \(totp.username)\n", level: 2) credResult.appendWithIndentation("Username: \(totp.userName ?? "")\n", level: 2)
case let .note(note): case let .note(note):
credResult.appendWithIndentation("Note: \(note.content)\n", level: 2) credResult.appendWithIndentation("Note: \(note.content)\n", level: 2)
case let .creditCard(card): case let .creditCard(card):
credResult.appendWithIndentation("FullName: \(card.fullName)\n", level: 2) credResult.appendWithIndentation("FullName: \(String(describing: card.fullName))\n", level: 2)
credResult.appendWithIndentation("Number: \(card.number)\n", level: 2) credResult.appendWithIndentation("Number: \(String(describing: card.number))\n", level: 2)
if let cardType = card.cardType { if let cardType = card.cardType {
credResult.appendWithIndentation("CardType: \(cardType)\n", level: 2) credResult.appendWithIndentation("CardType: \(cardType)\n", level: 2)
} }

View File

@ -1,16 +1,17 @@
#if SUPPORTS_CXP #if SUPPORTS_CXP
import AuthenticationServices import AuthenticationServices
@available(iOS 18.2, *) @available(iOS 26.0, *)
extension ASImportableItem { extension ASImportableItem {
/// Provides a fixture for `ASImportableItem`. /// Provides a fixture for `ASImportableItem`.
static func fixture( static func fixture(
id: Data = Data(capacity: 16), id: Data = Data(capacity: 16),
created: Date = .now, created: Date = .now,
lastModified: Date = .now, lastModified: Date = .now,
type: ASImportableItem.ItemType = .login,
title: String = "", title: String = "",
subtitle: String? = nil, subtitle: String? = nil,
favorite: Bool = false,
scope: ASImportableCredentialScope? = nil,
credentials: [ASImportableCredential] = [], credentials: [ASImportableCredential] = [],
tags: [String] = [] tags: [String] = []
) -> ASImportableItem { ) -> ASImportableItem {
@ -18,9 +19,10 @@ extension ASImportableItem {
id: id, id: id,
created: created, created: created,
lastModified: lastModified, lastModified: lastModified,
type: type,
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
favorite: favorite,
scope: scope,
credentials: credentials, credentials: credentials,
tags: tags tags: tags
) )

View File

@ -235,7 +235,7 @@ public class AppProcessor {
/// Handles importing credentials using Credential Exchange Protocol. /// Handles importing credentials using Credential Exchange Protocol.
/// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`. /// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`.
@available(iOSApplicationExtension 18.2, *) @available(iOSApplicationExtension 26.0, *)
public func handleImportCredentials(credentialImportToken: UUID) async { public func handleImportCredentials(credentialImportToken: UUID) async {
let route = AppRoute.tab(.vault(.importCXF( let route = AppRoute.tab(.vault(.importCXF(
.importCredentials(credentialImportToken: credentialImportToken) .importCredentials(credentialImportToken: credentialImportToken)

View File

@ -102,7 +102,7 @@ class ExportCXFProcessor: StateProcessor<ExportCXFState, ExportCXFAction, Export
private func startExport() async { private func startExport() async {
#if SUPPORTS_CXP #if SUPPORTS_CXP
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
coordinator.showAlert( coordinator.showAlert(
.defaultAlert( .defaultAlert(
title: Localizations.exportingFailed title: Localizations.exportingFailed
@ -123,7 +123,7 @@ class ExportCXFProcessor: StateProcessor<ExportCXFState, ExportCXFAction, Export
coordinator.hideLoadingOverlay() coordinator.hideLoadingOverlay()
try await services.exportCXFCiphersRepository.exportCredentials( try await services.exportCXFCiphersRepository.exportCredentials(
data: data, data: data,
presentationAnchor: { delegate.presentationAnchorForASCredentialExportManager() } presentationAnchor: { await delegate.presentationAnchorForASCredentialExportManager() }
) )
coordinator.navigate(to: .dismiss) coordinator.navigate(to: .dismiss)
} catch ASAuthorizationError.failed { } catch ASAuthorizationError.failed {
@ -162,5 +162,6 @@ class ExportCXFProcessor: StateProcessor<ExportCXFState, ExportCXFAction, Export
protocol ExportCXFProcessorDelegate: AnyObject { protocol ExportCXFProcessorDelegate: AnyObject {
/// Returns an `ASPresentationAnchor` to be used when creating an `ASCredentialExportManager`. /// Returns an `ASPresentationAnchor` to be used when creating an `ASCredentialExportManager`.
/// - Returns: An `ASPresentationAnchor`. /// - Returns: An `ASPresentationAnchor`.
func presentationAnchorForASCredentialExportManager() -> ASPresentationAnchor @MainActor
func presentationAnchorForASCredentialExportManager() async -> ASPresentationAnchor
} }

View File

@ -206,7 +206,7 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
CXFCredentialsResult(count: 10, type: .password), CXFCredentialsResult(count: 10, type: .password),
]) ])
if #available(iOS 18.2, *) { if #available(iOS 26.0, *) {
exportCXFCiphersRepository.getExportVaultDataForCXFResult = exportCXFCiphersRepository.getExportVaultDataForCXFResult =
.success( .success(
ASImportableAccount.fixture() ASImportableAccount.fixture()
@ -216,7 +216,7 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
await subject.perform(.mainButtonTapped) await subject.perform(.mainButtonTapped)
// this should never happen in the actual app but here is a test for it as well. // this should never happen in the actual app but here is a test for it as well.
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
XCTAssertEqual(coordinator.alertShown.count, 1) XCTAssertEqual(coordinator.alertShown.count, 1)
XCTAssertEqual(coordinator.alertShown[0].title, Localizations.exportingFailed) XCTAssertEqual(coordinator.alertShown[0].title, Localizations.exportingFailed)
return return
@ -231,8 +231,8 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// `perform(_:)` with `.mainButtonTapped` in `.prepared` status does nothing when there's no delegate. /// `perform(_:)` with `.mainButtonTapped` in `.prepared` status does nothing when there's no delegate.
@MainActor @MainActor
func test_perform_mainButtonTappedPreparedDoesNothingWhenDelegateNil() async throws { func test_perform_mainButtonTappedPreparedDoesNothingWhenDelegateNil() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
subject = ExportCXFProcessor( subject = ExportCXFProcessor(
@ -262,8 +262,8 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// `perform(_:)` with `.mainButtonTapped` in `.prepared` status throws when getting export data. /// `perform(_:)` with `.mainButtonTapped` in `.prepared` status throws when getting export data.
@MainActor @MainActor
func test_perform_mainButtonTappedPreparedThrowsGettingExportData() async throws { func test_perform_mainButtonTappedPreparedThrowsGettingExportData() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
subject.state.status = .prepared(itemsToExport: [ subject.state.status = .prepared(itemsToExport: [
@ -285,8 +285,8 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// `perform(_:)` with `.mainButtonTapped` in `.prepared` status throws when exporting credentials. /// `perform(_:)` with `.mainButtonTapped` in `.prepared` status throws when exporting credentials.
@MainActor @MainActor
func test_perform_mainButtonTappedPreparedThrowsExportingCredentials() async throws { func test_perform_mainButtonTappedPreparedThrowsExportingCredentials() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
subject.state.status = .prepared(itemsToExport: [ subject.state.status = .prepared(itemsToExport: [
@ -311,8 +311,8 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
/// when exporting credentials. /// when exporting credentials.
@MainActor @MainActor
func test_perform_mainButtonTappedPreparedThrowsAuthorizationExportingCredentials() async throws { func test_perform_mainButtonTappedPreparedThrowsAuthorizationExportingCredentials() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("This test requires iOS 18.2") throw XCTSkip("This test requires iOS 26.0")
} }
subject.state.status = .prepared(itemsToExport: [ subject.state.status = .prepared(itemsToExport: [
@ -339,7 +339,7 @@ class ExportCXFProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
func test_perform_mainButtonTappedPreparedNothing() async throws { func test_perform_mainButtonTappedPreparedNothing() async throws {
subject.state.status = .prepared(itemsToExport: []) subject.state.status = .prepared(itemsToExport: [])
await subject.perform(.mainButtonTapped) await subject.perform(.mainButtonTapped)
throw XCTSkip("This feature is available on iOS 18.2 or later compiling with Xcode 16.2 or later") throw XCTSkip("This feature is available on iOS 26.0 or later compiling with Xcode 26.0 or later")
} }
#endif #endif

View File

@ -80,7 +80,8 @@ extension ExportCXFCoordinator: HasErrorAlertServices {
// MARK: - ExportCXFProcessorDelegate // MARK: - ExportCXFProcessorDelegate
extension ExportCXFCoordinator: ExportCXFProcessorDelegate { extension ExportCXFCoordinator: ExportCXFProcessorDelegate {
func presentationAnchorForASCredentialExportManager() -> ASPresentationAnchor { @MainActor
func presentationAnchorForASCredentialExportManager() async -> ASPresentationAnchor {
stackNavigator?.rootViewController?.view.window ?? UIWindow() stackNavigator?.rootViewController?.view.window ?? UIWindow()
} }
} }

View File

@ -66,7 +66,7 @@ class ImportCXFProcessor: StateProcessor<ImportCXFState, Void, ImportCXFEffect>
/// Checks whether the CXF import feature is enabled. /// Checks whether the CXF import feature is enabled.
private func checkEnabled() async { private func checkEnabled() async {
guard #available(iOS 18.2, *), await services.configService.getFeatureFlag(.cxpImportMobile) else { guard #available(iOS 26.0, *), await services.configService.getFeatureFlag(.cxpImportMobile) else {
state.status = .failure(message: Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice) state.status = .failure(message: Localizations.importingFromAnotherProviderIsNotAvailableForThisDevice)
return return
} }
@ -80,7 +80,7 @@ class ImportCXFProcessor: StateProcessor<ImportCXFState, Void, ImportCXFEffect>
private func startImport() async { private func startImport() async {
#if SUPPORTS_CXP #if SUPPORTS_CXP
guard #available(iOS 18.2, *), let credentialImportToken = state.credentialImportToken else { guard #available(iOS 26.0, *), let credentialImportToken = state.credentialImportToken else {
coordinator.showAlert( coordinator.showAlert(
.defaultAlert( .defaultAlert(
title: Localizations.importError, title: Localizations.importError,

View File

@ -76,7 +76,7 @@ class ImportCXFProcessorTests: BitwardenTestCase {
/// policy applies to user. /// policy applies to user.
@MainActor @MainActor
func test_perform_appearedPersonalOwnership() async throws { func test_perform_appearedPersonalOwnership() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("CXP Import feature is not available on this device") throw XCTSkip("CXP Import feature is not available on this device")
} }
@ -98,7 +98,7 @@ class ImportCXFProcessorTests: BitwardenTestCase {
/// policy doesn't apply to user. /// policy doesn't apply to user.
@MainActor @MainActor
func test_perform_appearedFeatureFlagEnabled() async throws { func test_perform_appearedFeatureFlagEnabled() async throws {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
throw XCTSkip("CXP Import feature is not available on this device") throw XCTSkip("CXP Import feature is not available on this device")
} }
@ -330,7 +330,7 @@ class ImportCXFProcessorTests: BitwardenTestCase {
/// Checks whether the alert is shown when not in the correct iOS version for CXF Import to work. /// Checks whether the alert is shown when not in the correct iOS version for CXF Import to work.
@MainActor @MainActor
private func checkAlertShownWhenNotInCorrectIOSVersion() -> Bool { private func checkAlertShownWhenNotInCorrectIOSVersion() -> Bool {
guard #available(iOS 18.2, *) else { guard #available(iOS 26.0, *) else {
XCTAssertEqual( XCTAssertEqual(
coordinator.alertShown, coordinator.alertShown,
[ [

View File

@ -15,6 +15,8 @@ app_info_plist_path="Bitwarden/Application/Support/Info.plist"
if ! grep -q "SupportsCredentialExchange" $autofill_info_plist_path; then if ! grep -q "SupportsCredentialExchange" $autofill_info_plist_path; then
plutil -insert NSExtension.NSExtensionAttributes.ASCredentialProviderExtensionCapabilities.SupportsCredentialExchange -bool YES $autofill_info_plist_path plutil -insert NSExtension.NSExtensionAttributes.ASCredentialProviderExtensionCapabilities.SupportsCredentialExchange -bool YES $autofill_info_plist_path
plutil -insert NSExtension.NSExtensionAttributes.ASCredentialProviderExtensionCapabilities.SupportedCredentialExchangeVersions -array $autofill_info_plist_path
plutil -insert NSExtension.NSExtensionAttributes.ASCredentialProviderExtensionCapabilities.SupportedCredentialExchangeVersions.0 -string "1.0" $autofill_info_plist_path
fi fi
if ! grep -q "ASCredentialExchangeActivityType" $app_info_plist_path; then if ! grep -q "ASCredentialExchangeActivityType" $app_info_plist_path; then