diff --git a/Braintree.podspec b/Braintree.podspec index f5d273c832..01086b8d05 100644 --- a/Braintree.podspec +++ b/Braintree.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "Braintree" - s.version = "6.24.0" + s.version = "6.25.0" s.summary = "Braintree iOS SDK: Helps you accept card and alternative payments in your iOS app." s.description = <<-DESC Braintree is a full-stack payments platform for developers diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba1482596..95779eadbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,20 @@ ## unreleased * BraintreePayPal - * Add `BTPayPalRequest.userPhoneNumber` optional property * Add `BTContactInformation` request object * Add `BTPayPalCheckoutRequest.contactInformation` optional property + +## 6.25.0 (2024-12-11) +* BraintreePayPal + * Add `BTPayPalRequest.userPhoneNumber` optional property + * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) * BraintreeVenmo * Send `url` in `event_params` for App Switch events to PayPal's analytics service (FPTI) + * Add `BTVenmoClient(apiClient:universalLink:)` to use Universal Links when redirecting back from the Venmo flow +* BraintreeCore + * Deprecate `BTAppContextSwitcher.sharedInstance.returnURLScheme` +* BraintreeThreeDSecure + * Add `BTThreeDSecureRequest.requestorAppURL` ## 6.24.0 (2024-10-15) * BraintreePayPal diff --git a/Demo/Application/Features/VenmoViewController.swift b/Demo/Application/Features/VenmoViewController.swift index 0b6537f168..1668872139 100644 --- a/Demo/Application/Features/VenmoViewController.swift +++ b/Demo/Application/Features/VenmoViewController.swift @@ -2,14 +2,16 @@ import UIKit import BraintreeVenmo class VenmoViewController: PaymentButtonBaseViewController { - + // swiftlint:disable:next implicitly_unwrapped_optional var venmoClient: BTVenmoClient! let webFallbackToggle = Toggle(title: "Enable Web Fallback") let vaultToggle = Toggle(title: "Vault") - + let universalLinkReturnToggle = Toggle(title: "Use Universal Link Return") + override func viewDidLoad() { + super.heightConstraint = 150 super.viewDidLoad() venmoClient = BTVenmoClient(apiClient: apiClient) title = "Custom Venmo Button" @@ -18,7 +20,7 @@ class VenmoViewController: PaymentButtonBaseViewController { override func createPaymentButton() -> UIView { let venmoButton = createButton(title: "Venmo", action: #selector(tappedVenmo)) - let stackView = UIStackView(arrangedSubviews: [webFallbackToggle, vaultToggle, venmoButton]) + let stackView = UIStackView(arrangedSubviews: [webFallbackToggle, vaultToggle, universalLinkReturnToggle, venmoButton]) stackView.axis = .vertical stackView.spacing = 15 stackView.alignment = .fill @@ -40,7 +42,15 @@ class VenmoViewController: PaymentButtonBaseViewController { if vaultToggle.isOn { venmoRequest.vault = true } - + + if universalLinkReturnToggle.isOn { + venmoClient = BTVenmoClient( + apiClient: apiClient, + // swiftlint:disable:next force_unwrapping + universalLink: URL(string: "https://mobile-sdk-demo-site-838cead5d3ab.herokuapp.com/braintree-payments")! + ) + } + Task { do { let venmoAccount = try await venmoClient.tokenize(venmoRequest) diff --git a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist index 03c10f885f..0fff3896e4 100644 --- a/Demo/Application/Supporting Files/Braintree-Demo-Info.plist +++ b/Demo/Application/Supporting Files/Braintree-Demo-Info.plist @@ -41,7 +41,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 6.24.0 + 6.25.0 CFBundleURLTypes @@ -56,7 +56,7 @@ CFBundleVersion - 6.24.0 + 6.25.0 LSApplicationQueriesSchemes com.braintreepayments.Demo.payments diff --git a/Sources/BraintreeCore/BTAppContextSwitcher.swift b/Sources/BraintreeCore/BTAppContextSwitcher.swift index db875e268d..1ea5738eff 100644 --- a/Sources/BraintreeCore/BTAppContextSwitcher.swift +++ b/Sources/BraintreeCore/BTAppContextSwitcher.swift @@ -14,7 +14,22 @@ import UIKit /// The URL scheme to return to this app after switching to another app or opening a SFSafariViewController. /// This URL scheme must be registered as a URL Type in the app's info.plist, and it must start with the app's bundle ID. /// - Note: This property should only be used for the Venmo flow. - public var returnURLScheme: String = "" + @available( + *, + deprecated, + message: "returnURLScheme is deprecated and will be removed in a future version. Use BTVenmoClient(apiClient:universalLink:)." + ) + public var returnURLScheme: String { + get { _returnURLScheme } + set { _returnURLScheme = newValue } + } + + // swiftlint:disable identifier_name + /// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. + /// Property for `returnURLScheme`. Created to avoid deprecation warnings upon accessing + /// `returnURLScheme` directly within our SDK. Use this value instead. + public var _returnURLScheme: String = "" + // swiftlint:enable identifier_name // MARK: - Private Properties diff --git a/Sources/BraintreeCore/BTCoreConstants.swift b/Sources/BraintreeCore/BTCoreConstants.swift index aaa8eb3a20..a058997d4d 100644 --- a/Sources/BraintreeCore/BTCoreConstants.swift +++ b/Sources/BraintreeCore/BTCoreConstants.swift @@ -5,7 +5,7 @@ import Foundation @objcMembers public class BTCoreConstants: NSObject { /// :nodoc: This property is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. - public static var braintreeSDKVersion: String = "6.24.0" + public static var braintreeSDKVersion: String = "6.25.0" /// :nodoc: This property is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time. public static let callbackURLScheme: String = "sdk.ios.braintree" diff --git a/Sources/BraintreeCore/Info.plist b/Sources/BraintreeCore/Info.plist index 0a03531a0b..8724499eed 100644 --- a/Sources/BraintreeCore/Info.plist +++ b/Sources/BraintreeCore/Info.plist @@ -15,11 +15,11 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 6.24.0 + 6.25.0 CFBundleSignature ???? CFBundleVersion - 6.24.0 + 6.25.0 NSPrincipalClass diff --git a/Sources/BraintreeDataCollector/BTDataCollector.swift b/Sources/BraintreeDataCollector/BTDataCollector.swift index 097588f2fa..02d6f18cdb 100644 --- a/Sources/BraintreeDataCollector/BTDataCollector.swift +++ b/Sources/BraintreeDataCollector/BTDataCollector.swift @@ -147,15 +147,14 @@ import BraintreeCore var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecSuccess, - let existingItem = item as? [String: Any], - let data = existingItem[kSecValueData as String] as? Data, - let identifier = String(data: data, encoding: String.Encoding.utf8) { + let data = item as? Data, + let identifier = String(data: data, encoding: .utf8) { return identifier } // If not, generate a new one and save it let newIdentifier = UUID().uuidString - query[kSecValueData as String] = newIdentifier + query[kSecValueData as String] = newIdentifier.data(using: .utf8) query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly SecItemAdd(query as CFDictionary, nil) return newIdentifier diff --git a/Sources/BraintreePayPal/BTPayPalClient.swift b/Sources/BraintreePayPal/BTPayPalClient.swift index 0362ed1597..9d5ee0e8e5 100644 --- a/Sources/BraintreePayPal/BTPayPalClient.swift +++ b/Sources/BraintreePayPal/BTPayPalClient.swift @@ -284,6 +284,29 @@ import BraintreeDataCollector performSwitchRequest(appSwitchURL: url, paymentType: paymentType, completion: completion) } + func invokedOpenURLSuccessfully(_ success: Bool, url: URL, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { + if success { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchSucceeded, + isVaultRequest: isVaultRequest, + linkType: linkType, + payPalContextID: payPalContextID, + appSwitchURL: url + ) + BTPayPalClient.payPalClient = self + appSwitchCompletion = completion + } else { + apiClient.sendAnalyticsEvent( + BTPayPalAnalytics.appSwitchFailed, + isVaultRequest: isVaultRequest, + linkType: linkType, + payPalContextID: payPalContextID, + appSwitchURL: url + ) + notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + } + } + // MARK: - App Switch Methods func handleReturnURL(_ url: URL) { @@ -404,28 +427,7 @@ import BraintreeDataCollector } application.open(redirectURL) { success in - self.invokedOpenURLSuccessfully(success, completion: completion) - } - } - - private func invokedOpenURLSuccessfully(_ success: Bool, completion: @escaping (BTPayPalAccountNonce?, Error?) -> Void) { - if success { - apiClient.sendAnalyticsEvent( - BTPayPalAnalytics.appSwitchSucceeded, - isVaultRequest: isVaultRequest, - linkType: linkType, - payPalContextID: payPalContextID - ) - BTPayPalClient.payPalClient = self - appSwitchCompletion = completion - } else { - apiClient.sendAnalyticsEvent( - BTPayPalAnalytics.appSwitchFailed, - isVaultRequest: isVaultRequest, - linkType: linkType, - payPalContextID: payPalContextID - ) - notifyFailure(with: BTPayPalError.appSwitchFailed, completion: completion) + self.invokedOpenURLSuccessfully(success, url: redirectURL, completion: completion) } } diff --git a/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift b/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift index 43b3274951..d9ff3cc446 100644 --- a/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift +++ b/Sources/BraintreeThreeDSecure/BTThreeDSecureRequest.swift @@ -93,6 +93,10 @@ import BraintreeCore /// When using `BTThreeDSecureUIType.native`, all `BTThreeDSecureRenderType` options except `.html` must be set. public var renderTypes: [BTThreeDSecureRenderType]? + /// Optional. Three DS Requester APP URL Merchant app declaring their URL within the CReq message + /// so that the Authentication app can call the Merchant app after out of band authentication has occurred. + public var requestorAppURL: String? + /// A delegate for receiving information about the ThreeDSecure payment flow. public weak var threeDSecureRequestDelegate: BTThreeDSecureRequestDelegate? diff --git a/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift b/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift index 89b21f4113..99cd10f9c0 100644 --- a/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift +++ b/Sources/BraintreeThreeDSecure/BTThreeDSecureV2Provider.swift @@ -45,6 +45,10 @@ class BTThreeDSecureV2Provider { cardinalConfiguration.renderType = renderTypes.compactMap { $0.cardinalValue } } + if let requestorAppURL = request.requestorAppURL { + cardinalConfiguration.threeDSRequestorAppURL = requestorAppURL + } + guard let cardinalAuthenticationJWT = configuration.cardinalAuthenticationJWT else { completion(nil) return diff --git a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift index b9d561e064..862a1c9ea9 100644 --- a/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift +++ b/Sources/BraintreeVenmo/BTVenmoAppSwitchRedirectURL.swift @@ -22,9 +22,10 @@ struct BTVenmoAppSwitchRedirectURL { // MARK: - Initializer init( - returnURLScheme: String, paymentContextID: String, metadata: BTClientMetadata, + returnURLScheme: String?, + universalLink: URL?, forMerchantID merchantID: String?, accessToken: String?, bundleDisplayName: String?, @@ -46,9 +47,6 @@ struct BTVenmoAppSwitchRedirectURL { let base64EncodedBraintreeData = serializedBraintreeData?.base64EncodedString() queryParameters = [ - "x-success": constructRedirectURL(with: returnURLScheme, result: "success"), - "x-error": constructRedirectURL(with: returnURLScheme, result: "error"), - "x-cancel": constructRedirectURL(with: returnURLScheme, result: "cancel"), "x-source": bundleDisplayName, "braintree_merchant_id": merchantID, "braintree_access_token": accessToken, @@ -57,6 +55,16 @@ struct BTVenmoAppSwitchRedirectURL { "braintree_sdk_data": base64EncodedBraintreeData ?? "", "customerClient": "MOBILE_APP" ] + + if let universalLink { + queryParameters["x-success"] = universalLink.appendingPathComponent("success").absoluteString + queryParameters["x-error"] = universalLink.appendingPathComponent("error").absoluteString + queryParameters["x-cancel"] = universalLink.appendingPathComponent("cancel").absoluteString + } else if let returnURLScheme { + queryParameters["x-success"] = constructRedirectURL(with: returnURLScheme, result: "success") + queryParameters["x-error"] = constructRedirectURL(with: returnURLScheme, result: "error") + queryParameters["x-cancel"] = constructRedirectURL(with: returnURLScheme, result: "cancel") + } } // MARK: - Internal Methods diff --git a/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift b/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift index b1fcd30be4..569054b539 100644 --- a/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift +++ b/Sources/BraintreeVenmo/BTVenmoAppSwitchReturnURL.swift @@ -41,7 +41,7 @@ struct BTVenmoAppSwitchReturnURL { init?(url: URL) { let parameters = BTURLUtils.queryParameters(for: url) - if url.path == "/vzero/auth/venmo/success" { + if url.path.contains("success") { if let resourceID = parameters["resource_id"] { state = .succeededWithPaymentContext paymentContextID = resourceID @@ -50,12 +50,12 @@ struct BTVenmoAppSwitchReturnURL { nonce = parameters["paymentMethodNonce"] ?? parameters["payment_method_nonce"] username = parameters["username"] } - } else if url.path == "/vzero/auth/venmo/error" { + } else if url.path.contains("error") { state = .failed let errorMessage: String? = parameters["errorMessage"] ?? parameters["error_message"] let errorCode = Int(parameters["errorCode"] ?? parameters["error_code"] ?? "0") error = BTVenmoAppSwitchError.returnURLError(errorCode ?? 0, errorMessage) - } else if url.path == "/vzero/auth/venmo/cancel" { + } else if url.path.contains("cancel") { state = .canceled } else { state = .unknown @@ -68,6 +68,7 @@ struct BTVenmoAppSwitchReturnURL { /// - Parameter url: an app switch return URL /// - Returns: `true` if the url represents a Venmo Touch app switch return static func isValid(url: URL) -> Bool { - url.host == "x-callback-url" && url.path.hasPrefix("/vzero/auth/venmo/") + (url.scheme == "https" && (url.path.contains("cancel") || url.path.contains("success") || url.path.contains("error"))) + || (url.host == "x-callback-url" && url.path.hasPrefix("/vzero/auth/venmo/")) } } diff --git a/Sources/BraintreeVenmo/BTVenmoClient.swift b/Sources/BraintreeVenmo/BTVenmoClient.swift index f5fa291508..7193bb8758 100644 --- a/Sources/BraintreeVenmo/BTVenmoClient.swift +++ b/Sources/BraintreeVenmo/BTVenmoClient.swift @@ -46,9 +46,11 @@ import BraintreeCore /// Used for sending the type of flow, universal vs deeplink to FPTI private var linkType: LinkType? + private var universalLink: URL? + // MARK: - Initializer - /// Creates an Apple Pay client + /// Creates a Venmo client /// - Parameter apiClient: An API client @objc(initWithAPIClient:) public init(apiClient: BTAPIClient) { @@ -56,6 +58,16 @@ import BraintreeCore self.apiClient = apiClient } + /// Initialize a new Venmo client instance. + /// - Parameters: + /// - apiClient: The API Client + /// - universalLink: The URL for the Venmo app to redirect to after user authentication completes. Must be a valid HTTPS URL dedicated to Braintree app switch returns. + @objc(initWithAPIClient:universalLink:) + public convenience init(apiClient: BTAPIClient, universalLink: URL) { + self.init(apiClient: apiClient) + self.universalLink = universalLink + } + // MARK: - Public Methods /// Initiates Venmo login via app switch, which returns a BTVenmoAccountNonce when successful. @@ -69,7 +81,7 @@ import BraintreeCore public func tokenize(_ request: BTVenmoRequest, completion: @escaping (BTVenmoAccountNonce?, Error?) -> Void) { linkType = request.fallbackToWeb ? .universal : .deeplink apiClient.sendAnalyticsEvent(BTVenmoAnalytics.tokenizeStarted, isVaultRequest: shouldVault, linkType: linkType) - let returnURLScheme = BTAppContextSwitcher.sharedInstance.returnURLScheme + let returnURLScheme = BTAppContextSwitcher.sharedInstance._returnURLScheme if returnURLScheme.isEmpty { NSLog( @@ -151,9 +163,10 @@ import BraintreeCore do { let appSwitchURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: returnURLScheme, paymentContextID: paymentContextID, metadata: metadata, + returnURLScheme: returnURLScheme, + universalLink: self.universalLink, forMerchantID: merchantProfileID, accessToken: configuration.venmoAccessToken, bundleDisplayName: bundleDisplayName, diff --git a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift index 7fff4ad489..787e9ef356 100644 --- a/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift +++ b/UnitTests/BraintreePayPalTests/BTPayPalClient_Tests.swift @@ -982,6 +982,24 @@ class BTPayPalClient_Tests: XCTestCase { XCTAssertNil(lastPostParameters["merchant_app_return_url"] as? String) } + func testInvokedOpenURLSuccessfully_whenSuccess_sendsAppSwitchSucceededWithAppSwitchURL() { + let eventName = BTPayPalAnalytics.appSwitchSucceeded + let fakeURL = URL(string: "some-url")! + payPalClient.invokedOpenURLSuccessfully(true, url: fakeURL) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last!, eventName) + XCTAssertEqual(mockAPIClient.postedAppSwitchURL[eventName], fakeURL.absoluteString) + } + + func testInvokedOpenURLSuccessfully_whenFailure_sendsAppSwitchFailedWithAppSwitchURL() { + let eventName = BTPayPalAnalytics.appSwitchFailed + let fakeURL = URL(string: "some-url")! + payPalClient.invokedOpenURLSuccessfully(false, url: fakeURL) { _, _ in } + + XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.first!, eventName) + XCTAssertEqual(mockAPIClient.postedAppSwitchURL[eventName], fakeURL.absoluteString) + } + // MARK: - Analytics func testAPIClientMetadata_hasIntegrationSetToCustom() { diff --git a/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift b/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift index da92e9bc00..fef916e212 100644 --- a/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift +++ b/UnitTests/BraintreeVenmoTests/BTVenmoAppSwitchRedirectURL_Tests.swift @@ -7,9 +7,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testUrlSchemeURL_whenAllValuesAreInitialized_returnsURLWithPaymentContextID() { do { let requestURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: "url-scheme", + universalLink: nil, forMerchantID: "merchant-id", accessToken: "access-token", bundleDisplayName: "display-name", @@ -29,9 +30,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testAppSwitchURL_whenMerchantIDNil_throwsError() { do { _ = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: "url-scheme", + universalLink: nil, forMerchantID: nil, accessToken: "access-token", bundleDisplayName: "display-name", @@ -47,9 +49,10 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { func testUniversalLinkURL_whenAllValuesInitialized_returnsURLWithAllValues() { do { let requestURL = try BTVenmoAppSwitchRedirectURL( - returnURLScheme: "url-scheme", paymentContextID: "12345", metadata: BTClientMetadata(), + returnURLScheme: nil, + universalLink: URL(string: "https://mywebsite.com/braintree-payments"), forMerchantID: "merchant-id", accessToken: "access-token", bundleDisplayName: "display-name", @@ -60,9 +63,9 @@ class BTVenmoAppSwitchRedirectURL_Tests: XCTestCase { let components = URLComponents(string: requestURL.universalLinksURL()!.absoluteString) guard let queryItems = components?.queryItems else { XCTFail(); return } - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-success", value: "url-scheme://x-callback-url/vzero/auth/venmo/success"))) - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-error", value: "url-scheme://x-callback-url/vzero/auth/venmo/error"))) - XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-cancel", value: "url-scheme://x-callback-url/vzero/auth/venmo/cancel"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-success", value: "https://mywebsite.com/braintree-payments/success"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-error", value: "https://mywebsite.com/braintree-payments/error"))) + XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-cancel", value: "https://mywebsite.com/braintree-payments/cancel"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "x-source", value: "display-name"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "braintree_merchant_id", value: "merchant-id"))) XCTAssertTrue(queryItems.contains(URLQueryItem(name: "braintree_access_token", value: "access-token")))